chromium/ash/webui/os_feedback_ui/resources/search_page.ts

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

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import './help_content.js';
import './help_resources_icons.html.js';
import './os_feedback_shared.css.js';

import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {strictQuery} from 'chrome://resources/ash/common/typescript_utils/strict_query.js';
import {stringToMojoString16} from 'chrome://resources/js/mojo_type_util.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {btRegEx, buildWordMatcher, FeedbackFlowButtonClickEvent, FeedbackFlowState} from './feedback_flow.js';
import {showScrollingEffectOnStart, showScrollingEffects} from './feedback_utils.js';
import {getHelpContentProvider} from './mojo_interface_provider.js';
import {FeedbackContext, HelpContent, HelpContentProviderInterface, SearchRequest, SearchResponse} from './os_feedback_ui.mojom-webui.js';
import {domainQuestions, questionnaireBegin} from './questionnaire.js';
import {getTemplate} from './search_page.html.js';

/**  The maximum number of help contents wanted per search. */
const MAX_RESULTS = 5;

/**  The host of untrusted child page. */
export const OS_FEEDBACK_UNTRUSTED_ORIGIN = 'chrome-untrusted://os-feedback';

/**  Regular expression to check for wifi-related keywords. */
const wifiRegEx =
    buildWordMatcher(['wifi', 'wi-fi', 'internet', 'network', 'hotspot']);

/**  Regular expression to check for cellular-related keywords. */
const cellularRegEx = buildWordMatcher([
  '2G',   '3G',    '4G',      '5G',       'LTE',      'UMTS',
  'SIM',  'eSIM',  'mmWave',  'mobile',   'APN',      'IMEI',
  'IMSI', 'eUICC', 'carrier', 'T.Mobile', 'TMO',      'Verizon',
  'VZW',  'AT&T',  'MVNO',    'pin.lock', 'cellular',
]);

/**  Regular expression to check for display-related keywords. */
const displayRegEx = buildWordMatcher([
  'display',
  'displayport',
  'hdmi',
  'monitor',
  'panel',
  'screen',
]);

/**  Regular expression to check for USB-related keywords. */
const usbRegEx = buildWordMatcher([
  'USB',
  'USB-C',
  'Type-C',
  'TypeC',
  'USBC',
  'USBTypeC',
  'USBPD',
  'hub',
  'charger',
  'dock',
]);

/**  Regular expression to check for thunderbolt-related keywords. */
const thunderboltRegEx = buildWordMatcher([
  'Thunderbolt',
  'Thunderbolt3',
  'Thunderbolt4',
  'TBT',
  'TBT3',
  'TBT4',
  'TB3',
  'TB4',
]);

/**
 * @fileoverview
 * 'search-page' is the first step of the feedback tool. It displays live help
 *  contents relevant to the text entered by the user.
 */

const SearchPageElementBase = I18nMixin(PolymerElement);

export class SearchPageElement extends SearchPageElementBase {
  static get is() {
    return 'search-page' as const;
  }

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

  static get properties() {
    return {
      feedbackContext: {type: Object, readOnly: false, notify: true},
      descriptionTemplate: {
        type: String,
        readonly: true,
        observer: SearchPageElement.prototype.descriptionTemplateChanged,
      },
      descriptionPlaceholderText: {
        type: String,
        readonly: true,
        observer: SearchPageElement.prototype.descriptionPlaceholderTextChanged,
      },
      helpContentSearchResultCount: {
        type: Number,
        notify: true,
      },
      noHelpContentDisplayed: {
        type: Boolean,
        notify: true,
      },
    };
  }

  feedbackContext: FeedbackContext;
  descriptionTemplate = '';
  descriptionPlaceholderText: string = '';
  private helpContentSearchResultCount: number = 0;
  private noHelpContentDisplayed = false;
  private helpContentProvider: HelpContentProviderInterface;
  /**
   * The event handler called when the iframe is loaded. It is set in the
   * html.
   */
  private resolveIframeLoaded: Function;
  /**  A promise that resolves when the iframe loading is completed. */
  private iframeLoaded: Promise<void>;
  private iframe: HTMLIFrameElement|null;
  /**  The content list received when query is empty. */
  private popularHelpContentList: HelpContent[];
  /**
   * The list of questionnaire questions that have already been appended to
   * the input text.
   */
  private appendedQuestions: string[] = [];
  /**  Whether the search result content is returned with popular content. */
  private isPopularContentForTesting = false;
  /**  Timer used to add a delay to fire a new search. */
  private searchTimerID: number = -1;
  /**
   * The unique id of a query. Whenever a new query is scheduled, this number
   * will be incremented by 1. New query will have a bigger sequence number
   * than older queries.
   * @private {number}
   */
  private querySeqNo: number = 0;
  /**
   * The most recent query sequence number whose result has been posted to the
   * iframe and thus seen by the user. Results for two queries fired at
   * different times may come back in reverse order. By recording this number,
   * we can prevent displaying the result from older queries.
   */
  private lastPostedQuerySeqNo: number;
  /**
   * Delay in milliseconds before firing a new search.
   *
   * This variable needs to remain public because the unit tests need to
   * set its value.
   */
  searchTimoutInMs: number = 250;

  constructor() {
    super();

    this.helpContentProvider = getHelpContentProvider();
    this.lastPostedQuerySeqNo = -1;

    this.iframeLoaded = new Promise(resolve => {
      this.resolveIframeLoaded = resolve;
    });
    // Set focus on the input field and decide whether to show scrolling effect
    // after iframe is loaded.
    this.iframeLoaded.then(() => {
      this.focusInputElement();
      showScrollingEffectOnStart(this as HTMLElement);
    });
  }

  override ready() {
    super.ready();

    this.iframe = strictQuery('iframe', this.shadowRoot, HTMLIFrameElement);
    // Fetch popular help contents with empty query.
    this.fetchHelpContent(
        /* query= */ '', /* querySeqNo= */ this.getNextQuerySeqNo());

    this.getInputElement().addEventListener(
        'input', () => this.checkForShowQuestionnaire());

    window.addEventListener('message', (e: MessageEvent) => {
      const message = e.data;
      if (message.iframeHeight) {
        this.style.setProperty(
            '--iframe-height', message.iframeHeight.toString() + 'px');
      }
    }, false);
  }

  protected handleInputChanged(e: InputEvent): void {
    clearTimeout(this.searchTimerID);
    const textArea = e.target as HTMLTextAreaElement;
    const query = textArea.value.trim();

    // As the user is typing, hide the error message.
    if (query.length > 0) {
      this.hideError();
    }

    // When the user is not logged in, the feedback app does not allow access to
    // external websites. Therefore, search is not needed.
    if (!this.isUserLoggedIn()) {
      return;
    }

    const querySeqNo = this.getNextQuerySeqNo();
    this.searchTimerID = setTimeout(() => {
      this.fetchHelpContent(query, querySeqNo);
    }, this.searchTimoutInMs);
  }

  private getNextQuerySeqNo(): number {
    return this.querySeqNo++;
  }

  /**
   * When the feedback app is launched from OOBE or the login screen, the
   * categoryTag is set to "Login".
   */
  protected isUserLoggedIn(): boolean {
    return this.feedbackContext?.categoryTag !== 'Login';
  }

  /**
   * Fetches help content/popular search and notifies iframe if querySeqNo is
   * greater than previous.
   */
  private async fetchHelpContent(query: string, querySeqNo: number) {
    if (!this.iframe) {
      console.warn('untrusted iframe is not found');
      return;
    }

    // When the user is not logged in, the feedback app does not allow access to
    // external websites. Therefore, search is not needed.
    if (!this.isUserLoggedIn()) {
      return;
    }

    const request: SearchRequest = {
      query: stringToMojoString16(query),
      maxResults: MAX_RESULTS,
    };

    const isQueryEmpty: boolean = (query === '');

    let isPopularContent: boolean;

    let response: {response: SearchResponse};

    if (isQueryEmpty) {
      // Load popular help content if they are not loaded before.
      if (this.popularHelpContentList === undefined) {
        response = await this.helpContentProvider.getHelpContents(request);
        this.popularHelpContentList = response.response.results;
      }
      this.helpContentSearchResultCount = this.popularHelpContentList.length;
      isPopularContent = true;
    } else {
      response = await this.helpContentProvider.getHelpContents(request);
      isPopularContent = (response.response.results.length === 0);
      this.helpContentSearchResultCount = response.response.results.length;
    }

    this.isPopularContentForTesting = isPopularContent;
    const data = {
      contentList:
          (isPopularContent ? this.popularHelpContentList :
                              response!.response.results),
      isQueryEmpty: isQueryEmpty,
      isPopularContent: isPopularContent,
    };

    this.noHelpContentDisplayed = (data.contentList.length === 0);

    // Wait for the iframe to complete loading before postMessage.
    await this.iframeLoaded;

    // Results from an older query will be ignored.
    if (querySeqNo > this.lastPostedQuerySeqNo) {
      this.lastPostedQuerySeqNo = querySeqNo;
      // TODO(xiangdongkong): Use Mojo to communicate with untrusted page.
      this.iframe!.contentWindow!.postMessage(
          data, OS_FEEDBACK_UNTRUSTED_ORIGIN);
    }
  }

  private getInputElement(): HTMLTextAreaElement {
    return strictQuery(
        '#descriptionText', this.shadowRoot, HTMLTextAreaElement);
  }

  /**  Focus on the textarea element. */
  focusInputElement(): void {
    this.getInputElement().focus();
  }

  private onInputInvalid(): void {
    this.showError();
    this.focusInputElement();
  }

  private getErrorElement(): HTMLElement {
    return strictQuery('#emptyErrorContainer', this.shadowRoot, HTMLElement);
  }

  private showError(): void {
    // TODO(xiangdongkong): Change the textarea's aria-labelledby to ensure the
    // screen reader does (or doesn't) read the error, as appropriate.
    // If it does read the error, it should do so _before_ it reads the normal
    // description.
    const errorElement = this.getErrorElement();
    errorElement.hidden = false;
    errorElement.setAttribute('aria-hidden', 'false');

    const descriptionTextElement = this.getInputElement();
    descriptionTextElement.classList.add('has-error');
  }

  private hideError(): void {
    const errorElement = this.getErrorElement();

    if (errorElement.hidden) {
      return;
    }

    errorElement.hidden = true;
    errorElement.setAttribute('aria-hidden', 'true');

    const descriptionTextElement = this.getInputElement();
    descriptionTextElement.classList.remove('has-error');
  }

  protected feedbackWritingGuidanceUrl(): string {
    // TODO(xiangdongkong): append ?hl={the application locale} to the url.
    const url = 'https://support.google.com/chromebook/answer/2982029';
    return url;
  }

  private handleContinueButtonClicked(e: Event): void {
    e.stopPropagation();

    const textInput = this.getInputElement().value.trim();
    if (textInput.length === 0) {
      this.onInputInvalid();
    } else {
      this.dispatchEvent(new CustomEvent('continue-click', {
        composed: true,
        bubbles: true,
        detail:
            {currentState: FeedbackFlowState.SEARCH, description: textInput},
      }));
    }
  }

  setDescription(text: string): void {
    this.getInputElement().value = text;
  }

  protected descriptionTemplateChanged(currentTemplate: string): void {
    this.getInputElement().value = currentTemplate;
  }

  protected descriptionPlaceholderTextChanged(currentPlaceholder: string):
      void {
    if (currentPlaceholder === '') {
      this.getInputElement().placeholder = this.i18n('descriptionHint');
    } else {
      this.getInputElement().placeholder = currentPlaceholder;
    }
  }

  /**
   * Checks if any keywords have associated questionnaire in a domain. If so,
   * we append the questionnaire to the text input box.
   */
  private checkForShowQuestionnaire(): void {
    if (!this.feedbackContext.isInternalAccount) {
      return;
    }

    const toAppend = [];

    // Match user-entered description before the questionnaire to reduce false
    // positives due to matching the questionnaire questions and answers.
    const textarea = this.getInputElement();
    const value = textarea.value;
    const questionnaireBeginPos = value.indexOf(questionnaireBegin);
    const matchedText = questionnaireBeginPos >= 0 ?
        value.substring(0, questionnaireBeginPos) :
        value;

    if (btRegEx.test(matchedText)) {
      toAppend.push(...domainQuestions['bluetooth']);
    }

    if (wifiRegEx.test(matchedText)) {
      toAppend.push(...domainQuestions['wifi']);
    }

    if (cellularRegEx.test(matchedText)) {
      toAppend.push(...domainQuestions['cellular']);
    }

    if (displayRegEx.test(matchedText)) {
      toAppend.push(...domainQuestions['display']);
    }

    if (thunderboltRegEx.test(matchedText)) {
      toAppend.push(...domainQuestions['thunderbolt']);
    } else if (usbRegEx.test(matchedText)) {
      toAppend.push(...domainQuestions['usb']);
    }

    if (toAppend.length === 0) {
      return;
    }

    const savedCursor = textarea.selectionStart;
    if (this.appendedQuestions.length === 0) {
      textarea.value += '\n\n' + questionnaireBegin + '\n';
    }

    for (const question of toAppend) {
      if (this.appendedQuestions.includes(question)) {
        continue;
      }

      textarea.value += '* ' + question + ' \n';
      this.appendedQuestions.push(question);
    }

    // After appending text, the web engine automatically moves the cursor to
    // the end of the appended text, so we need to move the cursor back to where
    // the user was typing before.
    textarea.selectionEnd = savedCursor;
  }

  protected onContainerScroll(event: Event): void {
    showScrollingEffects(event, this as HTMLElement);
  }

  getSearchResultCountForTesting(): number {
    return this.helpContentSearchResultCount;
  }

  getIsPopularContentForTesting(): boolean {
    return this.isPopularContentForTesting;
  }

  getNextQuerySeqNoForTesting(): number {
    return this.querySeqNo;
  }

  setNextQuerySeqNoForTesting(nextQuerySeqNo: number): void {
    this.querySeqNo = nextQuerySeqNo;
  }

  getLastPostedQuerySeqNoForTesting(): number {
    return this.lastPostedQuerySeqNo;
  }
}

declare global {
  interface HTMLElementEventMap {
    'continue-click': FeedbackFlowButtonClickEvent;
  }

  interface HTMLElementTagNameMap {
    [SearchPageElement.is]: SearchPageElement;
  }
}

customElements.define(SearchPageElement.is, SearchPageElement);