chromium/ash/webui/os_feedback_ui/resources/file_attachment.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_dialog/cr_dialog.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import './help_resources_icons.html.js';
import './os_feedback_shared.css.js';

import {assert} from 'chrome://resources/ash/common/assert.js';
import {CrCheckboxElement} from 'chrome://resources/ash/common/cr_elements/cr_checkbox/cr_checkbox.js';
import {CrToastElement} from 'chrome://resources/ash/common/cr_elements/cr_toast/cr_toast.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 {BigBuffer} from 'chrome://resources/mojo/mojo/public/mojom/base/big_buffer.mojom-webui.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './file_attachment.html.js';
import {getFeedbackServiceProvider} from './mojo_interface_provider.js';
import {AttachedFile, FeedbackAppPreSubmitAction, FeedbackServiceProviderInterface} from './os_feedback_ui.mojom-webui.js';

/**
 * @fileoverview
 * 'file-attachment' allows users to select a file as an attachment to the
 *  report.
 */

const FileAttachmentElementBase = I18nMixin(PolymerElement);

export class FileAttachmentElement extends FileAttachmentElementBase {
  static get is() {
    return 'file-attachment' as const;
  }

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

  static get properties() {
    return {
      hasSelectedAFile: {
        type: Boolean,
        computed: 'computeHasSelectedFile(selectedFile)',
      },
    };
  }

  /**  The file selected if any to be attached to the report. */
  private selectedFile: File|null = null;

  /**  The name of the file selected. */
  protected selectedFileName: string;

  /**  Url of the selected image. */
  protected selectedImageUrl: string;

  /**  True when there is a file selected. */
  protected hasSelectedAFile: boolean;

  private feedbackServiceProvider: FeedbackServiceProviderInterface;

  constructor() {
    super();

    this.feedbackServiceProvider = getFeedbackServiceProvider();
  }

  override ready() {
    super.ready();
    // Set the aria description works the best for screen reader.
    // It reads the description when the checkbox is focused, and when it is
    // checked and unchecked.
    strictQuery('#selectFileCheckbox', this.shadowRoot, CrCheckboxElement)
        .ariaDescription = this.i18n('attachFileCheckboxArialLabel');
  }

  private computeHasSelectedFile(): boolean {
    return this.selectedFile != null;
  }

  /**  Gather the file name and data chosen. */
  async getAttachedFile(): Promise<AttachedFile|null> {
    const inputElement =
        strictQuery('#selectFileCheckbox', this.shadowRoot, CrCheckboxElement);
    if (!inputElement.checked) {
      return null;
    }
    if (!this.selectedFile) {
      return null;
    }

    const fileDataBuffer = await this.selectedFile.arrayBuffer();
    const fileDataView = new Uint8Array(fileDataBuffer);
    // fileData is of type BigBuffer which can take byte array format or
    // shared memory form. For now, byte array is being used for its simplicity.
    // For better performance, we may switch to shared memory.
    const fileData: BigBuffer = {bytes: Array.from(fileDataView)} as any;

    const attachedFile: AttachedFile = {
      fileName: {path: {path: this.selectedFile.name}},
      fileData: fileData,
    };

    return attachedFile;
  }

  /**  Get the image url when uploaded file is image type. */
  private async getImageUrl(file: File): Promise<string> {
    const fileDataBuffer = await file.arrayBuffer();
    const fileDataView = new Uint8Array(fileDataBuffer);
    const blob = new Blob([Uint8Array.from(fileDataView)], {type: file.type});

    const imageUrl = URL.createObjectURL(blob);
    return imageUrl;
  }

  protected handleSelectedImageClick(): void {
    const dialog =
        strictQuery('#selectedImageDialog', this.shadowRoot, HTMLDialogElement);
    dialog.showModal();
    this.feedbackServiceProvider.recordPreSubmitAction(
        FeedbackAppPreSubmitAction.kViewedImage);
  }

  protected handleSelectedImageDialogCloseClick(): void {
    const dialog =
        strictQuery('#selectedImageDialog', this.shadowRoot, HTMLDialogElement);
    dialog.close();
  }

  protected handleOpenFileInputClick(e: Event): void {
    e.preventDefault();
    const fileInput =
        strictQuery('#selectFileDialog', this.shadowRoot, HTMLInputElement);
    // Clear the value so that when the user selects the same file again, the
    // change event will be triggered. Otherwise, if the file size exceeds the
    // limit, the error alert will not be displayed when the user selects the
    // same file again.
    fileInput.value = '';
    fileInput.click();
  }

  protected handleFileSelectChange(e: Event): void {
    const fileInput = e.target as HTMLInputElement;
    // The feedback app takes maximum one attachment. And the file dialog is set
    // to accept one file only.
    if (fileInput.files!.length > 0) {
      this.handleSelectedFileHelper(fileInput.files![0]);
    }
  }

  private handleSelectedFileHelper(file: File): void {
    assert(file);
    // Maximum file size is 10MB.
    const MAX_ATTACH_FILE_SIZE_BYTES = 10 * 1024 * 1024;
    if (file.size > MAX_ATTACH_FILE_SIZE_BYTES) {
      strictQuery('#fileTooBigErrorMessage', this.shadowRoot, CrToastElement)
          .show();
      return;
    }
    this.selectedFile = file;
    this.selectedFileName = file.name;
    const checkboxElement =
        strictQuery('#selectFileCheckbox', this.shadowRoot, CrCheckboxElement);
    checkboxElement.checked = true;

    const buttonElement =
        strictQuery('#selectedImageButton', this.shadowRoot, HTMLButtonElement);
    // Add a preview image when selected file is image type.
    if (file.type.startsWith('image/')) {
      this.getImageUrl(file).then((imageUrl) => {
        this.selectedImageUrl = imageUrl;
        buttonElement.ariaLabel = this.i18n('previewImageAriaLabel', file.name);
      });
    } else {
      this.selectedImageUrl = '';
      buttonElement.ariaLabel = '';
    }
  }

  setSelectedFileForTesting(file: File): void {
    this.handleSelectedFileHelper(file);
  }

  setSelectedImageUrlForTesting(imgUrl: string): void {
    this.selectedImageUrl = imgUrl;
  }
}

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

customElements.define(FileAttachmentElement.is, FileAttachmentElement);