chromium/chrome/browser/resources/chromeos/app_install/app_install_dialog.ts

// Copyright 2023 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_auto_img/cr_auto_img.js';
import 'chrome://resources/cros_components/button/button.js';
import './strings.m.js';

import {ColorChangeUpdater} from 'chrome://resources/cr_components/color_change_listener/colors_css_updater.js';
import {Button} from 'chrome://resources/cros_components/button/button.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';

import type {DialogArgs} from './app_install.mojom-webui.js';
import {getTemplate} from './app_install_dialog.html.js';
import {BrowserProxy} from './browser_proxy.js';

window.addEventListener('load', () => {
  ColorChangeUpdater.forDocument().start();
});

enum DialogState {
  INSTALL = 'install',
  INSTALLING = 'installing',
  INSTALLED = 'installed',
  ALREADY_INSTALLED = 'already_installed',
  FAILED_INSTALL_ERROR = 'failed_install_error',
  NO_APP_ERROR = 'no_app_error',
  CONNECTION_ERROR = 'connection_error',
}

interface StateData {
  title: {
    iconIdQuery: string,
    labelId: string,
  };
  content?: {
    hidden?: boolean,
  };
  errorMessage?: {
    visible?: boolean, textId: string,
  };
  actionButton: {
    hidden?: boolean,
    disabled?: boolean,
    labelId?: string,
    handler?: () => void,
    handleOnce?: boolean,
    iconIdQuery?: string,
  };
  cancelButton: {
    disabled?: boolean, labelId: string,
  };
}

/**
 * @fileoverview
 * 'app-install-dialog' defines the UI for the ChromeOS app install dialog.
 */

class AppInstallDialogElement extends HTMLElement {
  static get is() {
    return 'app-install-dialog';
  }

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

  private proxy = BrowserProxy.getInstance();
  private initialStatePromise: Promise<DialogState>;
  private dialogStateDataMap: Record<DialogState, StateData>;
  private dialogArgs?: DialogArgs;

  constructor() {
    super();
    const template = document.createElement('template');
    template.innerHTML = AppInstallDialogElement.template as string;
    const fragment = template.content.cloneNode(true);
    this.attachShadow({mode: 'open'}).appendChild(fragment);

    this.initialStatePromise = this.initContent();

    this.dialogStateDataMap = {
      [DialogState.INSTALL]: {
        title: {
          iconIdQuery: '#title-icon-install',
          labelId: 'installAppToDevice',
        },
        actionButton: {
          labelId: 'install',
          handler: () => this.onInstallButtonClick(),
          handleOnce: true,
          iconIdQuery: '#action-icon-install',
        },
        cancelButton: {
          labelId: 'cancel',
        },
      },
      [DialogState.INSTALLING]: {
        title: {
          iconIdQuery: '#title-icon-install',
          labelId: 'installingApp',
        },
        actionButton: {
          disabled: true,
          labelId: 'installing',
          iconIdQuery: '#action-icon-installing',
        },
        cancelButton: {
          disabled: true,
          labelId: 'cancel',
        },
      },
      [DialogState.INSTALLED]: {
        title: {
          iconIdQuery: '#title-icon-installed',
          labelId: 'appInstalled',
        },
        actionButton: {
          labelId: 'openApp',
          handler: () => this.onOpenAppButtonClick(),
          iconIdQuery: '#action-icon-open-app',
        },
        cancelButton: {
          labelId: 'close',
        },
      },
      [DialogState.ALREADY_INSTALLED]: {
        title: {
          iconIdQuery: '#title-icon-installed',
          labelId: 'appAlreadyInstalled',
        },
        actionButton: {
          labelId: 'openApp',
          handler: () => this.onOpenAppButtonClick(),
          iconIdQuery: '#action-icon-open-app',
        },
        cancelButton: {
          labelId: 'close',
        },
      },
      [DialogState.FAILED_INSTALL_ERROR]: {
        title: {
          iconIdQuery: '#title-icon-error',
          labelId: 'failedInstall',
        },
        actionButton: {
          labelId: 'tryAgain',
          handler: () => this.onInstallButtonClick(),
          handleOnce: true,
          iconIdQuery: '#action-icon-try-again',
        },
        cancelButton: {
          labelId: 'cancel',
        },
      },
      [DialogState.NO_APP_ERROR]: {
        title: {
          iconIdQuery: '#title-icon-error',
          labelId: 'noAppErrorTitle',
        },
        content: {
          hidden: true,
        },
        errorMessage: {
          visible: true,
          textId: 'noAppErrorDescription',
        },
        actionButton: {
          hidden: true,
        },
        cancelButton: {
          labelId: 'close',
        },
      },
      [DialogState.CONNECTION_ERROR]: {
        title: {
          iconIdQuery: '#title-icon-connection-error',
          labelId: 'connectionErrorTitle',
        },
        content: {
          hidden: true,
        },
        errorMessage: {
          visible: true,
          textId: 'connectionErrorDescription',
        },
        actionButton: {
          labelId: 'tryAgain',
          handler: () => this.onTryAgainButtonClick(),
          handleOnce: true,
          iconIdQuery: '#action-icon-try-again',
        },
        cancelButton: {
          labelId: 'cancel',
        },
      },
    };
  }

  async initContent() {
    const cancelButton = this.$<Button>('.cancel-button');
    assert(cancelButton);
    cancelButton.addEventListener('click', this.onCancelButtonClick.bind(this));

    try {
      this.dialogArgs = (await this.proxy.handler.getDialogArgs()).dialogArgs;

      if (this.dialogArgs.noAppErrorArgs) {
        return DialogState.NO_APP_ERROR;
      }

      if (this.dialogArgs.connectionErrorActions) {
        return DialogState.CONNECTION_ERROR;
      }

      const appInfo = this.dialogArgs.appInfoArgs!.data;

      const nameElement = this.$<HTMLParagraphElement>('#name');
      assert(nameElement);
      nameElement.textContent = appInfo.name;

      const urlElement = this.$<HTMLAnchorElement>('#url-link');
      assert(urlElement);
      urlElement.textContent = new URL(appInfo.url.url).hostname;
      urlElement.setAttribute('href', new URL(appInfo.url.url).origin);

      const iconElement = this.$<HTMLImageElement>('#app-icon');
      assert(iconElement);
      iconElement.setAttribute('auto-src', appInfo.iconUrl.url);
      iconElement.setAttribute(
          'alt',
          loadTimeData.substituteString(
              loadTimeData.getString('iconAlt'), appInfo.name));

      if (appInfo.description) {
        this.$<HTMLDivElement>('#description').textContent =
            appInfo.description;
        this.$<HTMLDivElement>('#description-and-screenshots').hidden = false;
        this.$<HTMLHRElement>('#divider').hidden = false;
      }

      if (appInfo.screenshots[0]) {
        this.$<HTMLSpanElement>('#description-and-screenshots').hidden = false;
        this.$<HTMLHRElement>('#divider').hidden = false;
        this.$<HTMLDivElement>('#screenshot-container').hidden = false;
        const height = appInfo.screenshots[0].size.height /
            (appInfo.screenshots[0].size.width / 408);
        this.$<HTMLDivElement>('#screenshot-container').style.height =
            height.toString() + 'px';
        this.$<HTMLImageElement>('#screenshot').onload = () => {
          this.onScreenshotLoad();
        };
        this.$<HTMLImageElement>('#screenshot')
            .setAttribute('auto-src', appInfo.screenshots[0].url.url);
      }

      return appInfo.isAlreadyInstalled ? DialogState.ALREADY_INSTALLED :
                                          DialogState.INSTALL;
    } catch (e) {
      console.error(`Unable to get dialog arguments . Error: ${e}.`);
      return DialogState.NO_APP_ERROR;
    }
  }

  $<T extends Element>(query: string): T {
    return this.shadowRoot!.querySelector(query)!;
  }

  $$(query: string): HTMLElement[] {
    return Array.from(this.shadowRoot!.querySelectorAll(query));
  }

  async connectedCallback(): Promise<void> {
    this.changeDialogState(await this.initialStatePromise);
  }

  private onScreenshotLoad(): void {
    this.$<HTMLImageElement>('#screenshot')!.style.display = 'block';
  }

  private onCancelButtonClick(): void {
    if (this.$<Button>('.cancel-button').disabled) {
      return;
    }
    this.proxy.handler.closeDialog();
  }

  private async onInstallButtonClick() {
    this.changeDialogState(DialogState.INSTALLING);

    // Keep the installing state shown for at least 2 seconds to give the
    // impression that the PWA is being installed.
    const [{installed: install_result}] = await Promise.all([
      this.dialogArgs!.appInfoArgs!.actions.installApp(),
      new Promise(resolve => setTimeout(resolve, 2000)),
    ]);

    this.changeDialogState(
        install_result ? DialogState.INSTALLED :
                         DialogState.FAILED_INSTALL_ERROR);
  }

  private async onOpenAppButtonClick() {
    this.dialogArgs!.appInfoArgs!.actions.launchApp();
    this.proxy.handler.closeDialog();
  }

  private async onTryAgainButtonClick() {
    this.dialogArgs!.connectionErrorActions!.tryAgain();
    // TODO(b/333460441): Run the retry logic within the same dialog instead of
    // creating a new one.
    this.proxy.handler.closeDialog();
  }

  private changeDialogState(state: DialogState) {
    const data = this.dialogStateDataMap![state];
    assert(data);

    for (const icon of this.$$('.title-icon')) {
      icon.style.display = 'none';
    }
    this.$<HTMLElement>(data.title.iconIdQuery).style.display = 'block';
    this.$<HTMLElement>('#title').textContent =
        loadTimeData.getString(data.title.labelId);

    const contentCard = this.$<HTMLElement>('#content-card')!;
    contentCard.style.display = data.content?.hidden ? 'none' : 'block';

    const errorMessage = this.$<HTMLElement>('#error-message')!;
    errorMessage.style.display = data.errorMessage?.visible ? 'block' : 'none';
    if (data.errorMessage) {
      errorMessage.textContent =
          loadTimeData.getString(data.errorMessage.textId);
    }

    const actionButton = this.$<Button>('.action-button')!;
    assert(actionButton);
    actionButton.style.display = data.actionButton.hidden ? 'none' : 'block';
    actionButton.disabled = Boolean(data.actionButton.disabled);
    if (data.actionButton.labelId) {
      actionButton.label = loadTimeData.getString(data.actionButton.labelId);
    }
    if (data.actionButton.handler) {
      actionButton.addEventListener(
          'click', data.actionButton.handler,
          {once: Boolean(data.actionButton.handleOnce)});
    }
    for (const icon of this.$$('.action-icon')) {
      icon.setAttribute('slot', '');
    }
    if (data.actionButton.iconIdQuery) {
      this.$<HTMLElement>(data.actionButton.iconIdQuery)
          .setAttribute('slot', 'leading-icon');
    }

    const cancelButton = this.$<Button>('.cancel-button');
    assert(cancelButton);
    cancelButton.disabled = Boolean(data.cancelButton.disabled);
    cancelButton.label = loadTimeData.getString(data.cancelButton.labelId);
  }
}

customElements.define(AppInstallDialogElement.is, AppInstallDialogElement);