chromium/chrome/browser/resources/feedback/app.ts

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

import '../../strings.m.js';
import './feedback_shared_styles.css.js';
// <if expr="chromeos_ash">
import './js/jelly_colors.js';

// </if>

import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {OpenWindowProxyImpl} from 'chrome://resources/js/open_window_proxy.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';

import {getCss} from './app.css.js';
import {getHtml} from './app.html.js';
import {FeedbackBrowserProxyImpl} from './js/feedback_browser_proxy.js';
import {BT_DEVICE_REGEX, BT_REGEX, CANNOT_CONNECT_REGEX, CELLULAR_REGEX, DISPLAY_REGEX, FAST_PAIR_REGEX, NEARBY_SHARE_REGEX, SMART_LOCK_REGEX, TETHER_REGEX, THUNDERBOLT_REGEX, USB_REGEX, WIFI_REGEX} from './js/feedback_regexes.js';
import {FEEDBACK_LANDING_PAGE, FEEDBACK_LANDING_PAGE_TECHSTOP, FEEDBACK_LEGAL_HELP_URL, FEEDBACK_PRIVACY_POLICY_URL, FEEDBACK_TERM_OF_SERVICE_URL, openUrlInAppWindow} from './js/feedback_util.js';
import {domainQuestions, questionnaireBegin, questionnaireNotification} from './js/questionnaire.js';
import {takeScreenshot} from './js/take_screenshot.js';

const MAX_ATTACH_FILE_SIZE: number = 3 * 1024 * 1024;
const MAX_SCREENSHOT_WIDTH: number = 100;

export class AppElement extends CrLitElement {
  static get is() {
    return 'feedback-app';
  }

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

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

  private formOpenTime: number = new Date().getTime();
  private attachedFileBlob: Blob|null = null;

  /**
   * Which questions have been appended to the issue description text area.
   */
  private appendedQuestions: {[key: string]: boolean} = {};

  /**
   * The object will be manipulated by sendReport().
   */
  private feedbackInfo: chrome.feedbackPrivate.FeedbackInfo = {
    attachedFile: undefined,
    attachedFileBlobUuid: undefined,
    autofillMetadata: '',
    categoryTag: undefined,
    description: '...',
    descriptionPlaceholder: undefined,
    email: undefined,
    flow: chrome.feedbackPrivate.FeedbackFlow.REGULAR,
    fromAutofill: false,
    includeBluetoothLogs: false,
    pageUrl: undefined,
    sendHistograms: undefined,
    systemInformation: [],
    useSystemWindowFrame: false,
    isOffensiveOrUnsafe: undefined,
    aiMetadata: undefined,
  };

  /**
   * Initializes our page.
   * Flow:
   * .) DOMContent Loaded        -> . Request feedbackInfo object
   *                                . Setup page event handlers
   * .) Feedback Object Received -> . take screenshot
   *                                . request email
   *                                . request System info
   *                                . request i18n strings
   * .) Screenshot taken         -> . Show Feedback window.
   */
  override async connectedCallback() {
    super.connectedCallback();

    // Initialize `browserProxy` only after tests had a chance to do setup
    // steps, one of which is to replace the prod proxy with a test version.
    // this.browserProxy = FeedbackBrowserProxyImpl.getInstance();

    const dialogArgs =
        FeedbackBrowserProxyImpl.getInstance().getDialogArguments();
    if (dialogArgs) {
      this.feedbackInfo = JSON.parse(dialogArgs);
    }

    await this.applyData(this.feedbackInfo);

    // Setup our event handlers.
    this.getRequiredElement('#attach-file')
        .addEventListener('change', (e: Event) => this.onFileSelected(e));
    this.getRequiredElement('#attach-file')
        .addEventListener('click', this.onOpenFileDialog.bind(this));
    this.getRequiredElement('#send-report-button').onclick =
        this.sendReport.bind(this);
    this.getRequiredElement('#cancel-button').onclick = (e: Event) =>
        this.cancel(e);
    this.getRequiredElement('#remove-attached-file').onclick =
        this.clearAttachedFile.bind(this);

    // Dispatch event used by tests.
    this.dispatchEvent(new CustomEvent('ready-for-testing'));
  }

  /**
   * Apply updates based on the received `FeedbackInfo` object.
   * @return A promise signaling that all UI updates have finished.
   */
  private applyData(feedbackInfo: chrome.feedbackPrivate.FeedbackInfo):
      Promise<void> {
    if (feedbackInfo.includeBluetoothLogs) {
      assert(
          feedbackInfo.flow ===
          chrome.feedbackPrivate.FeedbackFlow.GOOGLE_INTERNAL);
      this.getRequiredElement('#description-text')
          .addEventListener(
              'input', (e: Event) => this.checkForSendBluetoothLogs(e));
    }

    if (feedbackInfo.showQuestionnaire) {
      assert(
          feedbackInfo.flow ===
          chrome.feedbackPrivate.FeedbackFlow.GOOGLE_INTERNAL);
      this.getRequiredElement('#description-text')
          .addEventListener(
              'input', (e: Event) => this.checkForShowQuestionnaire(e));
    }

    if (this.shadowRoot!.querySelector<HTMLElement>(
            '#autofill-checkbox-container') != null &&
        feedbackInfo.flow ===
            chrome.feedbackPrivate.FeedbackFlow.GOOGLE_INTERNAL &&
        feedbackInfo.fromAutofill) {
      this.getRequiredElement('#autofill-checkbox-container').hidden = false;
    }

    this.getRequiredElement('#description-text').textContent =
        feedbackInfo.description;
    if (feedbackInfo.descriptionPlaceholder) {
      this.getRequiredElement<HTMLTextAreaElement>('#description-text')
          .placeholder = feedbackInfo.descriptionPlaceholder;
    }
    if (feedbackInfo.pageUrl) {
      this.getRequiredElement<HTMLInputElement>('#page-url-text').value =
          feedbackInfo.pageUrl;
    }

    const isAiFlow: boolean =
        feedbackInfo.flow === chrome.feedbackPrivate.FeedbackFlow.AI;

    if (isAiFlow) {
      this.getRequiredElement('#free-form-text').textContent =
          loadTimeData.getString('freeFormTextAi');
      this.getRequiredElement('#offensive-container').hidden = false;
      this.getRequiredElement('#log-id-container').hidden = false;
    }

    const isSeaPenFlow: boolean|undefined =
        isAiFlow && feedbackInfo.aiMetadata?.includes('from_sea_pen');

    if (isSeaPenFlow) {
      this.getRequiredElement('#log-id-container').hidden = true;
      this.getRequiredElement('#screenshot-container').hidden = true;
      this.getRequiredElement('#sys-info-container').hidden = true;
    }

    const whenScreenshotUpdated = takeScreenshot().then((screenshotCanvas) => {
      // We've taken our screenshot, show the feedback page without any
      // further delay.
      window.requestAnimationFrame(this.resizeAppWindow.bind(this));

      FeedbackBrowserProxyImpl.getInstance().showDialog();

      // Allow feedback to be sent even if the screenshot failed.
      if (!screenshotCanvas) {
        const checkbox =
            this.getRequiredElement<HTMLInputElement>('#screenshot-checkbox');
        checkbox.disabled = true;
        checkbox.checked = false;
        return Promise.resolve();
      }

      return new Promise<void>((resolve) => {
        screenshotCanvas.toBlob((blob) => {
          const image =
              this.getRequiredElement<HTMLImageElement>('#screenshot-image');
          image.src = URL.createObjectURL(blob!);
          // Only set the alt text when the src url is available, otherwise we'd
          // get a broken image picture instead. crbug.com/773985.
          image.alt = 'screenshot';
          image.classList.toggle(
              'wide-screen', image.width > MAX_SCREENSHOT_WIDTH);
          feedbackInfo.screenshot = blob!;
          resolve();
        });
      });
    });

    const whenEmailUpdated = isAiFlow ?
        Promise.resolve() :
        FeedbackBrowserProxyImpl.getInstance().getUserEmail().then((email) => {
          // Never add an empty option.
          if (!email) {
            return;
          }
          const optionElement = document.createElement('option');
          optionElement.value = email;
          optionElement.text = email;
          optionElement.selected = true;
          // Make sure the "Report anonymously" option comes last.
          this.getRequiredElement('#user-email-drop-down')
              .insertBefore(
                  optionElement,
                  this.getRequiredElement('#anonymous-user-option'));

          // Now we can unhide the user email section:
          this.getRequiredElement('#user-email').hidden = false;
          // Only show email consent checkbox when an email address exists.
          this.getRequiredElement('#consent-container').hidden = false;
        });

    // An extension called us with an attached file.
    if (feedbackInfo.attachedFile) {
      this.getRequiredElement('#attached-filename-text').textContent =
          feedbackInfo.attachedFile.name;
      this.attachedFileBlob = feedbackInfo.attachedFile.data!;
      this.getRequiredElement('#custom-file-container').hidden = false;
      this.getRequiredElement('#attach-file').hidden = true;
    }

    // No URL, file attachment for login screen feedback.
    if (feedbackInfo.flow === chrome.feedbackPrivate.FeedbackFlow.LOGIN) {
      this.getRequiredElement('#page-url').hidden = true;
      this.getRequiredElement('#attach-file-container').hidden = true;
      this.getRequiredElement('#attach-file-note').hidden = true;
    }

    const autofillMetadataUrlElement =
        this.shadowRoot!.querySelector<HTMLElement>('#autofill-metadata-url');

    if (autofillMetadataUrlElement) {
      // Opens a new window showing the full anonymized autofill metadata.
      autofillMetadataUrlElement.onclick = (e: Event) => {
        e.preventDefault();

        FeedbackBrowserProxyImpl.getInstance().showAutofillMetadataInfo(
            feedbackInfo.autofillMetadata!);
      };

      autofillMetadataUrlElement.onauxclick = (e: Event) => {
        e.preventDefault();
      };
    }

    const sysInfoUrlElement =
        this.shadowRoot!.querySelector<HTMLElement>('#sys-info-url');
    if (sysInfoUrlElement) {
      // Opens a new window showing the full anonymized system+app
      // information.
      sysInfoUrlElement.onclick = (e: Event) => {
        e.preventDefault();

        FeedbackBrowserProxyImpl.getInstance().showSystemInfo();
      };

      sysInfoUrlElement.onauxclick = (e: Event) => {
        e.preventDefault();
      };
    }

    const histogramUrlElement =
        this.shadowRoot!.querySelector<HTMLElement>('#histograms-url');
    if (histogramUrlElement) {
      histogramUrlElement.onclick = (e: Event) => {
        e.preventDefault();

        FeedbackBrowserProxyImpl.getInstance().showMetrics();
      };

      histogramUrlElement.onauxclick = (e: Event) => {
        e.preventDefault();
      };
    }

    // The following URLs don't open on login screen, so hide them.
    // TODO(crbug.com/40144717): Find a solution to display them properly.
    // Update: the bluetooth and assistant logs links will work on login
    // screen now. But to limit the scope of this CL, they are still hidden.
    if (feedbackInfo.flow !== chrome.feedbackPrivate.FeedbackFlow.LOGIN) {
      const legalHelpPageUrlElement =
          this.shadowRoot!.querySelector<HTMLElement>('#legal-help-page-url');
      if (legalHelpPageUrlElement) {
        this.setupLinkHandlers(
            legalHelpPageUrlElement, FEEDBACK_LEGAL_HELP_URL,
            false /* useAppWindow */);
      }

      const privacyPolicyUrlElement =
          this.shadowRoot!.querySelector<HTMLElement>('#privacy-policy-url');
      if (privacyPolicyUrlElement) {
        this.setupLinkHandlers(
            privacyPolicyUrlElement, FEEDBACK_PRIVACY_POLICY_URL,
            false /* useAppWindow */);
      }

      const termsOfServiceUrlElement =
          this.shadowRoot!.querySelector<HTMLElement>('#terms-of-service-url');
      if (termsOfServiceUrlElement) {
        this.setupLinkHandlers(
            termsOfServiceUrlElement, FEEDBACK_TERM_OF_SERVICE_URL,
            false /* useAppWindow */);
      }
    }

    // Make sure our focus starts on the description field.
    this.getRequiredElement('#description-text').focus();

    return Promise.all([whenScreenshotUpdated, whenEmailUpdated])
        .then(() => {});
  }

  private async sendFeedbackReport(useSystemInfo: boolean) {
    const ID = Math.round(Date.now() / 1000);
    const FLOW = this.feedbackInfo.flow;

    const result = await FeedbackBrowserProxyImpl.getInstance().sendFeedback(
        this.feedbackInfo, useSystemInfo, this.formOpenTime);

    if (result.status === chrome.feedbackPrivate.Status.SUCCESS) {
      if (FLOW !== chrome.feedbackPrivate.FeedbackFlow.LOGIN &&
          result.landingPageType !==
              chrome.feedbackPrivate.LandingPageType.NO_LANDING_PAGE) {
        const landingPage = result.landingPageType ===
                chrome.feedbackPrivate.LandingPageType.NORMAL ?
            FEEDBACK_LANDING_PAGE :
            FEEDBACK_LANDING_PAGE_TECHSTOP;
        OpenWindowProxyImpl.getInstance().openUrl(landingPage);
      }
    } else {
      console.warn(
          'Feedback: Report for request with ID ' + ID +
          ' will be sent later.');
    }
    this.scheduleWindowClose();
  }

  /**
   * Reads the selected file when the user selects a file.
   * @param fileSelectedEvent The onChanged event for the file input box.
   */
  private onFileSelected(fileSelectedEvent: Event) {
    // <if expr="chromeos_ash">
    // This is needed on CrOS. Otherwise, the feedback window will stay behind
    // the Chrome window.
    FeedbackBrowserProxyImpl.getInstance().showDialog();
    // </if>

    const file = (fileSelectedEvent.target as HTMLInputElement).files![0];
    if (!file) {
      // User canceled file selection.
      this.attachedFileBlob = null;
      return;
    }

    if (file.size > MAX_ATTACH_FILE_SIZE) {
      this.getRequiredElement('#attach-error').hidden = false;

      // Clear our selected file.
      this.getRequiredElement<HTMLInputElement>('#attach-file').value = '';
      this.attachedFileBlob = null;
      return;
    }

    this.attachedFileBlob = file.slice();
  }

  /**
   * Called when user opens the file dialog. Hide 'attach-error' before file
   * dialog is open to prevent a11y bug https://crbug.com/1020047
   */
  private onOpenFileDialog() {
    this.getRequiredElement('#attach-error').hidden = true;
  }

  /**
   * Clears the file that was attached to the report with the initial request.
   * Instead we will now show the attach file button in case the user wants to
   * attach another file.
   */
  private clearAttachedFile() {
    this.getRequiredElement('#custom-file-container').hidden = true;
    this.attachedFileBlob = null;
    this.feedbackInfo.attachedFile = undefined;
    this.getRequiredElement('#attach-file').hidden = false;
  }

  /**
   * Sets up the event handlers for the given |anchorElement|.
   * @param anchorElement The <a> html element.
   * @param url The destination URL for the link.
   * @param useAppWindow true if the URL should be opened inside a new App
   *     Window, false if it should be opened in a new tab.
   */
  private setupLinkHandlers(
      anchorElement: HTMLElement, url: string, useAppWindow: boolean) {
    anchorElement.onclick = (e: Event) => {
      e.preventDefault();
      if (useAppWindow) {
        openUrlInAppWindow(url);
      } else {
        window.open(url, '_blank');
      }
    };

    anchorElement.onauxclick = (e: Event) => {
      e.preventDefault();
    };
  }

  /**
   * Checks if any keywords related to bluetooth have been typed. If they are,
   * we show the bluetooth logs option, otherwise hide it.
   * @param inputEvent The input event for the description textarea.
   */
  private checkForSendBluetoothLogs(inputEvent: Event) {
    const value = (inputEvent.target as HTMLInputElement).value;
    const isRelatedToBluetooth = BT_REGEX.test(value) ||
        CANNOT_CONNECT_REGEX.test(value) || TETHER_REGEX.test(value) ||
        SMART_LOCK_REGEX.test(value) || NEARBY_SHARE_REGEX.test(value) ||
        FAST_PAIR_REGEX.test(value) || BT_DEVICE_REGEX.test(value);
    this.getRequiredElement('#bluetooth-checkbox-container').hidden =
        !isRelatedToBluetooth;
  }

  /**
   * Checks if any keywords have associated questionnaire in a domain. If so,
   * we append the questionnaire in
   * getRequiredElement('description-text').
   * @param inputEvent The input event for the description textarea.
   */
  private checkForShowQuestionnaire(inputEvent: Event) {
    const toAppend = [];

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

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

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

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

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

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

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

    const textarea =
        this.getRequiredElement<HTMLTextAreaElement>('#description-text');
    const savedCursor = textarea.selectionStart;
    if (Object.keys(this.appendedQuestions).length === 0) {
      textarea.value += '\n\n' + questionnaireBegin + '\n';
      this.getRequiredElement('#questionnaire-notification').textContent =
          questionnaireNotification;
    }

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

      textarea.value += '* ' + question + ' \n';
      this.appendedQuestions[question] = true;
    }

    // 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;
  }

  /**
   * Updates the description-text box based on whether it was valid.
   * If invalid, indicate an error to the user. If valid, remove indication of
   * the error.
   */
  private updateDescription(wasValid: boolean) {
    // Set visibility of the alert text for users who don't use a screen
    // reader.
    this.getRequiredElement('#description-empty-error').hidden = wasValid;

    // Change the textarea's aria-labelled by 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 description =
        this.getRequiredElement<HTMLTextAreaElement>('#description-text');
    description.setAttribute(
        'aria-labelledby',
        (wasValid ? '' : 'description-empty-error ') + 'free-form-text');
    // Indicate whether input is valid.
    description.setAttribute('aria-invalid', String(!wasValid));
    if (!wasValid) {
      // Return focus to field so user can correct error.
      description.focus();
    }

    // We may have added or removed a line of text, so make sure the app window
    // is the right size.
    this.resizeAppWindow();
  }

  /**
   * Sends the report; after the report is sent, we need to be redirected to
   * the landing page, but we shouldn't be able to navigate back, hence
   * we open the landing page in a new tab and sendReport closes this tab.
   * @return Whether the report was sent.
   */
  private sendReport(): boolean {
    const textarea =
        this.getRequiredElement<HTMLTextAreaElement>('#description-text');
    if (textarea.value.length === 0) {
      this.updateDescription(false);
      return false;
    }
    // This isn't strictly necessary, since if we get past this point we'll
    // succeed, but for future-compatibility (and in case we later add more
    // failure cases after this), re-hide the alert and reset the aria label.
    this.updateDescription(true);

    // Prevent double clicking from sending additional reports.
    this.getRequiredElement<HTMLButtonElement>('#send-report-button').disabled =
        true;
    if (!this.feedbackInfo.attachedFile && this.attachedFileBlob) {
      this.feedbackInfo.attachedFile = {
        name: this.getRequiredElement<HTMLInputElement>('#attach-file').value,
        data: this.attachedFileBlob,
      };
    }

    const consentCheckboxValue: boolean =
        this.getRequiredElement<HTMLInputElement>('#consent-checkbox').checked;
    this.feedbackInfo.systemInformation = [
      {
        key: 'feedbackUserCtlConsent',
        value: String(consentCheckboxValue),
      },
    ];

    const isAiFlow: boolean =
        this.feedbackInfo.flow === chrome.feedbackPrivate.FeedbackFlow.AI;
    const isSeaPenFlow: boolean|undefined =
        isAiFlow && this.feedbackInfo.aiMetadata?.includes('from_sea_pen');
    if (isAiFlow) {
      this.feedbackInfo.isOffensiveOrUnsafe =
          this.getRequiredElement<HTMLInputElement>('#offensive-checkbox')
              .checked;
      if (isSeaPenFlow ||
          !this.getRequiredElement<HTMLInputElement>('#log-id-checkbox')
               .checked) {
        this.feedbackInfo.aiMetadata = undefined;
      }
    }

    this.feedbackInfo.description = textarea.value;
    this.feedbackInfo.pageUrl =
        this.getRequiredElement<HTMLInputElement>('#page-url-text').value;
    this.feedbackInfo.email =
        this.getRequiredElement<HTMLSelectElement>('#user-email-drop-down')
            .value;

    let useSystemInfo = false;
    let useHistograms = false;
    const checkbox =
        this.shadowRoot!.querySelector<HTMLInputElement>('#sys-info-checkbox');
    // SeaPen flow doesn't collect system info data.
    if (checkbox != null && checkbox.checked && !isSeaPenFlow) {
      // Send histograms along with system info.
      useHistograms = true;
      useSystemInfo = true;
    }

    const autofillCheckbox = this.shadowRoot!.querySelector<HTMLInputElement>(
        '#autofill-metadata-checkbox');
    if (autofillCheckbox != null && autofillCheckbox.checked &&
        !this.getRequiredElement('#autofill-checkbox-container').hidden) {
      this.feedbackInfo.sendAutofillMetadata = true;
    }

    this.feedbackInfo.sendHistograms = useHistograms;

    if (this.getRequiredElement<HTMLInputElement>('#screenshot-checkbox')
            .checked) {
      // The user is okay with sending the screenshot and tab titles.
      this.feedbackInfo.sendTabTitles = true;
    } else {
      // The user doesn't want to send the screenshot, so clear it.
      this.feedbackInfo.screenshot = undefined;
    }

    let productId: number|undefined =
        parseInt('' + this.feedbackInfo.productId, 10);
    if (isNaN(productId)) {
      // For apps that still use a string value as the |productId|, we must
      // clear that value since the API uses an integer value, and a conflict in
      // data types will cause the report to fail to be sent.
      productId = undefined;
    }
    this.feedbackInfo.productId = productId;

    // Request sending the report, show the landing page (if allowed)
    this.sendFeedbackReport(useSystemInfo);

    return true;
  }

  /**
   * Click listener for the cancel button.
   */
  private cancel(e: Event) {
    e.preventDefault();
    this.scheduleWindowClose();
  }

  private resizeAppWindow() {
    // TODO(crbug.com/1167223): The UI is now controlled by a WebDialog delegate
    // which is set to not resizable for now. If needed, a message handler can
    // be added to respond to resize request.
  }

  /**
   * Close the window after 100ms delay.
   */
  private scheduleWindowClose() {
    setTimeout(() => FeedbackBrowserProxyImpl.getInstance().closeDialog(), 100);
  }

  /**
   * TODO(crbug.com/41481648): A helper function in favor of converting feedback
   * UI from non-web component HTML to PolymerElement. It's better to be
   * replaced by polymer's $ helper dictionary.
   */
  getRequiredElement<T extends HTMLElement = HTMLElement>(query: string): T {
    const el = this.shadowRoot!.querySelector<T>(query);
    assert(el);
    assert(el instanceof HTMLElement);
    return el;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'feedback-app': AppElement;
  }
}

customElements.define(AppElement.is, AppElement);