chromium/ash/webui/recorder_app_ui/resources/components/onboarding-dialog.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 './cra/cra-button.js';
import './cra/cra-image.js';
import './speaker-label-consent-dialog-content.js';

import {
  css,
  html,
  PropertyDeclarations,
  PropertyValues,
} from 'chrome://resources/mwc/lit/index.js';

import {i18n, NoArgStringName} from '../core/i18n.js';
import {usePlatformHandler} from '../core/lit/context.js';
import {ReactiveLitElement} from '../core/reactive/lit.js';
import {signal} from '../core/reactive/signal.js';
import {
  settings,
  SpeakerLabelEnableState,
  TranscriptionEnableState,
} from '../core/state/settings.js';
import {assertExhaustive, assertInstanceof} from '../core/utils/assert.js';

import {
  DESCRIPTION_NAMES as SPEAKER_LABEL_DIALOG_DESCRIPTION_NAMES,
} from './speaker-label-consent-dialog-content.js';

/**
 * A dialog for showing the onboarding flow.
 */
export class OnboardingDialog extends ReactiveLitElement {
  static override styles = css`
    :host {
      display: block;
    }

    #dialog {
      background: var(--cros-sys-base_elevated);
      border: none;
      border-radius: 20px;
      box-shadow: var(--cros-sys-app_elevation3);
      color: var(--cros-sys-on_surface);
      display: flex;
      flex-flow: column;
      height: 512px;
      padding: 0;

      /* Want at least 80px left/right margin. */
      width: min(512px, 100vw - 160px);

      &::backdrop {
        background: var(--cros-sys-scrim);
        pointer-events: none;
      }

      /* From CrOS dialog style. Min width for Recorder App is 480px. */
      @media (width < 520px) {
        width: 360px;
      }
    }

    #illust {
      align-items: center;
      background: var(--cros-sys-highlight_shape);
      display: flex;
      height: 236px;
      justify-content: center;
      overflow: hidden;
      width: 100%;
    }

    #content {
      display: flex;
      flex: 1;
      flex-flow: column;
      min-height: 0;
      padding: 32px 32px 28px;
    }

    #header {
      font: var(--cros-display-7-font);
      margin-bottom: 16px;
    }

    #description {
      color: var(--cros-sys-on_surface_variant);
      flex: 1;
      font: var(--cros-body-1-font);
      margin-bottom: 32px;
      overflow-y: auto;
    }

    #buttons {
      display: flex;
      flex-flow: row;
      gap: 8px;
      justify-content: right;

      & > .left {
        margin-right: auto;
      }
    }
  `;

  static override properties: PropertyDeclarations = {
    open: {type: Boolean},
  };

  /**
   * Whether the dialog is opened.
   */
  open = false;

  /**
   * The currently shown step index starting from 0.
   */
  step = signal<0|1|2>(0);

  private readonly platformHandler = usePlatformHandler();

  get dialog(): HTMLDivElement {
    return assertInstanceof(
      this.shadowRoot?.getElementById('dialog'),
      HTMLDivElement,
    );
  }

  override updated(changedProperties: PropertyValues<this>): void {
    if (changedProperties.has('open')) {
      if (this.open) {
        this.dialog.showPopover();
      } else {
        this.dialog.hidePopover();
      }
    }
  }

  private close() {
    this.dispatchEvent(new Event('close'));
  }

  private renderDialog(
    imageName: string,
    header: string,
    description: RenderResult,
    buttons: RenderResult,
  ): RenderResult {
    // TODO(pihsun): Extract this to a separate component if any other place
    // need unclosable modal dialog.
    // We can't use <dialog> / <md-dialog> / <cra-dialog> here since the dialog
    // is always cancelable by pressing ESC, and the onboarding flow should not
    // be cancelable.
    // See https://issues.chromium.org/issues/346597066.
    return html`<div id="dialog" popover="manual" ?inert=${!this.open}>
      <div id="illust">
        <cra-image .name=${imageName}></cra-image>
      </div>
      <div id="content">
        <div id="header">${header}</div>
        <div id="description">${description}</div>
        <div id="buttons">${buttons}</div>
      </div>
    </div>`;
  }

  override render(): RenderResult {
    // Note that all the onboarding_ images are currently placeholders and
    // don't use dynamic color tokens yet.
    // TODO: b/344785475 - Change to final illustration when ready.
    switch (this.step.value) {
      case 0: {
        const nextStep = () => {
          if (this.platformHandler.sodaState.value.kind === 'unavailable') {
            // SODA isn't available on this platform. Don't ask for enabling
            // transcription or speaker label.
            this.close();
            return;
          }
          this.step.value = 1;
        };
        return this.renderDialog(
          'onboarding_welcome',
          i18n.onboardingDialogWelcomeHeader,
          i18n.onboardingDialogWelcomeDescription,
          html`<cra-button
            .label=${i18n.onboardingDialogWelcomeNextButton}
            @click=${nextStep}
          ></cra-button>`,
        );
      }
      case 1: {
        const enableTranscription = () => {
          settings.mutate((s) => {
            s.transcriptionEnabled = TranscriptionEnableState.ENABLED;
          });
          this.platformHandler.installSoda();
          if (!this.platformHandler.canUseSpeakerLabel.value) {
            // Speaker label isn't supported on this platform.
            this.close();
            return;
          }
          this.step.value = 2;
        };
        const disableTranscription = () => {
          settings.mutate((s) => {
            s.transcriptionEnabled = TranscriptionEnableState.DISABLED_FIRST;
          });
          this.close();
        };
        return this.renderDialog(
          'onboarding_transcription',
          i18n.onboardingDialogTranscriptionHeader,
          i18n.onboardingDialogTranscriptionDescription,
          html`
            <cra-button
              .label=${i18n.onboardingDialogTranscriptionDeferButton}
              class="left"
              @click=${this.close}
            ></cra-button>
            <cra-button
              .label=${i18n.onboardingDialogTranscriptionCancelButton}
              @click=${disableTranscription}
            ></cra-button>
            <cra-button
              .label=${i18n.onboardingDialogTranscriptionTurnOnButton}
              @click=${enableTranscription}
            ></cra-button>
          `,
        );
      }
      case 2: {
        const ALLOW_BUTTON_NAME: NoArgStringName =
          'onboardingDialogSpeakerLabelAllowButton';
        const DISALLOW_BUTTON_NAME: NoArgStringName =
          'onboardingDialogSpeakerLabelDisallowButton';
        const disableSpeakerLabel = () => {
          settings.mutate((s) => {
            s.speakerLabelEnabled = SpeakerLabelEnableState.DISABLED_FIRST;
          });
          this.platformHandler.recordSpeakerLabelConsent(
            false,
            SPEAKER_LABEL_DIALOG_DESCRIPTION_NAMES,
            DISALLOW_BUTTON_NAME,
          );
          this.close();
        };

        const enableSpeakerLabel = () => {
          settings.mutate((s) => {
            s.speakerLabelEnabled = SpeakerLabelEnableState.ENABLED;
          });
          this.platformHandler.recordSpeakerLabelConsent(
            true,
            SPEAKER_LABEL_DIALOG_DESCRIPTION_NAMES,
            ALLOW_BUTTON_NAME,
          );
          this.close();
        };

        return this.renderDialog(
          'onboarding_speaker_label',
          i18n.onboardingDialogSpeakerLabelHeader,
          html`<speaker-label-consent-dialog-content>
          </speaker-label-consent-dialog-content>`,
          html`
            <cra-button
              .label=${i18n.onboardingDialogSpeakerLabelDeferButton}
              class="left"
              @click=${this.close}
            ></cra-button>
            <cra-button
              .label=${i18n[DISALLOW_BUTTON_NAME]}
              @click=${disableSpeakerLabel}
            ></cra-button>
            <cra-button
              .label=${i18n[ALLOW_BUTTON_NAME]}
              @click=${enableSpeakerLabel}
            ></cra-button>
          `,
        );
      }
      default:
        assertExhaustive(this.step.value);
    }
  }
}

window.customElements.define('onboarding-dialog', OnboardingDialog);

declare global {
  interface HTMLElementTagNameMap {
    'onboarding-dialog': OnboardingDialog;
  }
}