chromium/chrome/browser/resources/side_panel/read_anything/language_menu.ts

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

import '//resources/cr_elements/cr_icon_button/cr_icon_button.js';
import '//resources/cr_elements/cr_icon/cr_icon.js';
import '//resources/cr_elements/icons_lit.html.js';
import '//resources/cr_elements/cr_dialog/cr_dialog.js';
import '//resources/cr_elements/cr_input/cr_input.js';
import '//resources/cr_elements/cr_toggle/cr_toggle.js';
import './icons.html.js';

import type {CrDialogElement} from '//resources/cr_elements/cr_dialog/cr_dialog.js';
import {I18nMixinLit} from '//resources/cr_elements/i18n_mixin_lit.js';
import {WebUiListenerMixinLit} from '//resources/cr_elements/web_ui_listener_mixin_lit.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';

import {toastDurationMs, ToolbarEvent} from './common.js';
import {getCss} from './language_menu.css.js';
import {getHtml} from './language_menu.html.js';
import {AVAILABLE_GOOGLE_TTS_LOCALES, convertLangOrLocaleForVoicePackManager, VoiceClientSideStatusCode} from './voice_language_util.js';

export interface LanguageMenuElement {
  $: {
    languageMenu: CrDialogElement,
  };
}

interface Notification {
  isError: boolean;
  text?: string;
}

interface LanguageDropdownItem {
  readableLanguage: string;
  checked: boolean;
  languageCode: string;
  notification: Notification;
  // Whether this toggle should be disabled
  disabled: boolean;
}

function isDownloading(voiceStatus: VoiceClientSideStatusCode) {
  switch (voiceStatus) {
    case VoiceClientSideStatusCode.SENT_INSTALL_REQUEST:
    case VoiceClientSideStatusCode.SENT_INSTALL_REQUEST_ERROR_RETRY:
    case VoiceClientSideStatusCode.INSTALLED_AND_UNAVAILABLE:
      return true;
    case VoiceClientSideStatusCode.AVAILABLE:
    case VoiceClientSideStatusCode.ERROR_INSTALLING:
    case VoiceClientSideStatusCode.INSTALL_ERROR_ALLOCATION:
    case VoiceClientSideStatusCode.NOT_INSTALLED:
      return false;
    default:
      // This ensures the switch statement is exhaustive
      return voiceStatus satisfies never;
  }
}

// Returns whether `substring` is a non-case-sensitive substring of `value`
function isSubstring(value: string, substring: string): boolean {
  return value.toLowerCase().includes(substring.toLowerCase());
}

const LanguageMenuElementBase =
    WebUiListenerMixinLit(I18nMixinLit(CrLitElement));

export class LanguageMenuElement extends LanguageMenuElementBase {
  static get is() {
    return 'language-menu';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      enabledLangs: {type: Array},
      availableVoices: {type: Array},
      localeToDisplayName: {type: Object},
      voicePackInstallStatus: {type: Object},
      selectedLang: {type: String},
      lastDownloadedLang: {type: String},
      languageSearchValue_: {type: String},
      currentNotifications_: {type: Array},
      toastTitle_: {type: String},
      availableLanguages_: {type: Array},
    };
  }

  override willUpdate(changedProperties: PropertyValues<this>) {
    super.willUpdate(changedProperties);

    const changedPrivateProperties =
        changedProperties as Map<PropertyKey, unknown>;

    if (changedProperties.has('selectedLang') ||
        changedProperties.has('localeToDisplayName') ||
        changedPrivateProperties.has('currentNotifications_') ||
        changedPrivateProperties.has('languageSearchValue_')) {
      this.availableLanguages_ = this.computeAvailableLanguages_();
    }

    if (changedProperties.has('lastDownloadedLang')) {
      this.toastTitle_ = this.getLanguageDownloadedTitle_();
    }
  }

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);

    if (changedProperties.has('voicePackInstallStatus')) {
      this.updateNotifications_(
          /* newVoiceStatuses= */ this.voicePackInstallStatus,
          /* oldVoiceStatuses= */
          changedProperties.get('voicePackInstallStatus'));
    }
  }

  selectedLang: string;
  localeToDisplayName: {[lang: string]: string} = {};
  enabledLangs: string[] = [];
  lastDownloadedLang: string;

  availableVoices: SpeechSynthesisVoice[];
  protected languageSearchValue_: string = '';
  protected toastTitle_: string = '';
  protected toastDuration_: number = toastDurationMs;
  voicePackInstallStatus: {[language: string]: VoiceClientSideStatusCode};
  protected availableLanguages_: LanguageDropdownItem[] = [];
  // Use this variable instead of AVAILABLE_GOOGLE_TTS_LOCALES
  // directly to better aid in testing.
  localesOfLangPackVoices: Set<string> =
      this.getSupportedNaturalVoiceDownloadLocales();

  // The current notifications that should be used in the language menu.
  // This is cleared each time the language menu reopens. After the language
  // menu reopens, only new changes to voicePackInstallStatus will be reflected
  // in notifications.
  private currentNotifications_:
      {[language: string]: VoiceClientSideStatusCode} = {};

  // Returns a copy of voicePackInstallStatus to use as a snapshot of the
  // current state. Before copying over the map, check the diff of
  // the new voicePackInstallStatus and our previous snapshot. If there are
  // any differences, add these to the currentNotifications_ map.
  private updateNotifications_(
      newVoiceStatuses: {[language: string]: VoiceClientSideStatusCode},
      oldVoiceStatuses?: {[language: string]: VoiceClientSideStatusCode}) {
    for (const lang of Object.keys(newVoiceStatuses)) {
      const newStatus = newVoiceStatuses[lang];
      // Since the downloading messages are cleared quickly, we should still
      // show "downloading" notifications, even if they were previously shown.
      if (isDownloading(newStatus)) {
        this.setNotification(lang, newStatus);
      } else if (oldVoiceStatuses && oldVoiceStatuses[lang] !== newStatus) {
        // Update the notification status for recently changed language keys.
        // Only show updates that occur while the language menu is open- don't
        // show notifications if updates occurred before the menu opened.
        this.setNotification(lang, newStatus);
      }
    }
  }

  private setNotification(lang: string, status: VoiceClientSideStatusCode) {
    this.currentNotifications_ = {
      ...this.currentNotifications_,
      [lang]: status,
    };
  }
  protected closeLanguageMenu_() {
    this.$.languageMenu.close();
  }

  protected onClearSearchClick_() {
    this.languageSearchValue_ = '';
  }

  protected onToggleChange_(e: Event) {
    const index =
        Number.parseInt((e.currentTarget as HTMLElement).dataset['index']!);
    const language = this.availableLanguages_[index].languageCode;

    this.fire(ToolbarEvent.LANGUAGE_TOGGLE, {language});
  }

  private getDisplayName(lang: string) {
    const langLower = lang.toLowerCase();
    return this.localeToDisplayName[langLower] || langLower;
  }

  private getLanguageDownloadedTitle_() {
    if (!this.lastDownloadedLang) {
      return '';
    }
    const langDisplayName = this.getDisplayName(this.lastDownloadedLang);
    return loadTimeData.getStringF(
        'readingModeVoiceDownloadedTitle', langDisplayName);
  }

  private getSupportedNaturalVoiceDownloadLocales(): Set<string> {
    if (chrome.readingMode.isLanguagePackDownloadingEnabled &&
        chrome.readingMode.isChromeOsAsh) {
      return AVAILABLE_GOOGLE_TTS_LOCALES;
    }
    return new Set([]);
  }

  private computeAvailableLanguages_(): LanguageDropdownItem[] {
    if (!this.availableVoices) {
      return [];
    }

    const selectedLangLowerCase = this.selectedLang?.toLowerCase();

    const availableLangs: string[] = [...new Set([
      ...this.localesOfLangPackVoices,
      ...this.availableVoices.map(({lang}) => lang.toLowerCase()),
    ])];

    // Sort the list of languages alphabetically by display name.
    availableLangs.sort((lang1, lang2) => {
      return this.getDisplayName(lang1).localeCompare(
          this.getDisplayName(lang2));
    });

    return availableLangs
        .filter(
            // Check whether the search term matches the readable lang (e.g.
            // 'ras' will match 'Portugues (Brasil)'), and also if it matches
            // the language code (e.g. 'pt-br' matches 'Portugues (Brasil)')
            lang => isSubstring(
                        /* value= */ this.getDisplayName(lang),
                        /* substring= */ this.languageSearchValue_) ||
                isSubstring(
                        /* value= */ lang,
                        /* substring= */ this.languageSearchValue_))
        .map(lang => ({
               readableLanguage: this.getDisplayName(lang),
               checked: this.enabledLangs.includes(lang),
               languageCode: lang,
               notification: this.getNotificationFor(lang),
               disabled: this.enabledLangs.includes(lang) &&
                   (lang.toLowerCase() === selectedLangLowerCase),
             }));
  }

  private hasAvailableNaturalVoices(lang: string): boolean {
    return this.localesOfLangPackVoices.has(lang.toLowerCase());
  }

  private getNotificationFor(lang: string): Notification {
    // Don't show notification text for a non-Google TTS language, as we're
    // not attempting a download.
    if (!this.hasAvailableNaturalVoices(lang)) {
      return {isError: false};
    }

    // Convert the lang code string to the language-pack format
    const voicePackLanguage = convertLangOrLocaleForVoicePackManager(lang);
    // No need to check the install status if the language is missing.
    if (!voicePackLanguage) {
      return {isError: false};
    }

    const notification = this.currentNotifications_[voicePackLanguage];
    if (notification === undefined) {
      return {isError: false};
    }

    // TODO(b/300259625): Show more error messages.
    switch (notification) {
      case VoiceClientSideStatusCode.SENT_INSTALL_REQUEST:
      case VoiceClientSideStatusCode.SENT_INSTALL_REQUEST_ERROR_RETRY:
      case VoiceClientSideStatusCode.INSTALLED_AND_UNAVAILABLE:
        return {isError: false, text: 'readingModeLanguageMenuDownloading'};
      case VoiceClientSideStatusCode.ERROR_INSTALLING:
        // There's not a specific error code from the language pack installer
        // for internet connectivity, but if there's an installation error
        // and we detect we're offline, we can assume that the install error
        // was due to lack of internet connection.
        // TODO(b/40927698): Consider setting the error status directly in
        // app.ts so that this can be reused by the voice menu when other
        // errors are added to the voice menu.
        if (!window.navigator.onLine) {
          return {isError: true, text: 'readingModeLanguageMenuNoInternet'};
        }
        // Show a generic error message.
        return {isError: true, text: 'languageMenuDownloadFailed'};
      case VoiceClientSideStatusCode.INSTALL_ERROR_ALLOCATION:
        // If we get an allocation error but voices exist for the given
        // language, show an allocation error specific to downloading high
        // quality voices.
        if (this.availableVoices.some(
                voice => voice.lang.toLowerCase() === lang)) {
          return {isError: true, text: 'allocationErrorHighQuality'};
        }
        return {isError: true, text: 'allocationError'};
      case VoiceClientSideStatusCode.AVAILABLE:
      case VoiceClientSideStatusCode.NOT_INSTALLED:
        return {isError: false};
      default:
        // This ensures the switch statement is exhaustive
        return notification satisfies never;
    }
  }

  // Runtime errors were thrown when this.i18n() was called in a Polymer
  // computed bindining callback function, so instead we call this.i18n from the
  // html via a wrapper.
  protected i18nWraper(s: string|undefined): string {
    return s ? this.i18n(s) : '';
  }


  protected searchHasLanguages(): boolean {
    // We should only show the "No results" string when there are no available
    // languages and there is a valid search term.
    return (this.availableLanguages_.length > 0) ||
        (!this.languageSearchValue_) ||
        (this.languageSearchValue_.trim().length === 0);
  }

  protected onLanguageSearchValueChanged_(e: CustomEvent<{value: string}>) {
    this.languageSearchValue_ = e.detail.value;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'language-menu': LanguageMenuElement;
  }
}

customElements.define(LanguageMenuElement.is, LanguageMenuElement);