chromium/ash/webui/personalization_app/resources/js/user/avatar_list_element.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.

/**
 * @fileoverview The avatar-list component displays the list of avatar images
 * that the user can select from.
 */

import 'chrome://resources/ash/common/personalization/personalization_shared_icons.html.js';

import {isNonEmptyArray} from 'chrome://resources/ash/common/sea_pen/sea_pen_utils.js';
import {assert} from 'chrome://resources/js/assert.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';
import {Url} from 'chrome://resources/mojo/url/mojom/url.mojom-webui.js';

import {DefaultUserImage, UserImage} from '../../personalization_app.mojom-webui.js';
import {isUserAvatarCustomizationSelectorsEnabled} from '../load_time_booleans.js';
import {setErrorAction} from '../personalization_actions.js';
import {WithPersonalizationStore} from '../personalization_store.js';
import {isSelectionEvent} from '../utils.js';

import {AvatarCameraElement, AvatarCameraMode} from './avatar_camera_element.js';
import {getTemplate} from './avatar_list_element.html.js';
import {fetchDefaultUserImages} from './user_controller.js';
import {getUserProvider} from './user_interface_provider.js';
import {selectLastExternalUserImageUrl} from './user_selectors.js';
import {getAvatarUrl} from './utils.js';

export interface AvatarListElement {
  $: {avatarCamera: AvatarCameraElement};
}

enum OptionId {
  LAST_EXTERNAL_IMAGE = 'lastExternalImage',
  OPEN_CAMERA = 'openCamera',
  OPEN_VIDEO = 'openVideo',
  PROFILE_IMAGE = 'profileImage',
  OPEN_FOLDER = 'openFolder',
}

interface EnumeratedOption {
  id: OptionId;
  class: string;
  imgSrc?: string;
  icon: string;
  title: string;
}

interface DefaultOption {
  id: string;
  class: string;
  imgSrc: string;
  icon: string;
  title: string;
  defaultImageIndex: number;
}

type Option = EnumeratedOption|DefaultOption;

function isDefaultOption(option: Option): option is DefaultOption {
  return option &&
      typeof (option as DefaultOption).defaultImageIndex === 'number';
}

function camelToKebab(className: string): string {
  return className.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
}

export class AvatarListElement extends WithPersonalizationStore {
  static get is() {
    return 'avatar-list';
  }

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

  static get properties() {
    return {
      defaultUserImages_: Array,

      profileImage_: Object,

      image_: Object,

      lastExternalUserImageUrl_: {
        type: Object,
        observer: 'onLastExternalUserImageUrlChanged_',
      },

      /** The presence of a device camera. */
      isCameraPresent_: {
        type: Boolean,
        value: false,
        observer: 'onIsCameraPresentChanged_',
      },

      /** Whether the camera is off, photo mode, or video mode. */
      cameraMode_: {
        type: String,
        value: null,
      },

      /** Whether custom avatar selectors are enabled. */
      isCustomizationSelectorsEnabled_: {
        type: Boolean,
        value() {
          return isUserAvatarCustomizationSelectorsEnabled();
        },
      },

      /**
       * List of options to be displayed to the user.
       */
      options_: {
        type: Array,
        value: [],
      },
    };
  }

  static get observers() {
    return [
      'updateOptions_(isCameraPresent_, profileImage_, lastExternalUserImageUrl_, defaultUserImages_)',
    ];
  }

  private defaultUserImages_: DefaultUserImage[]|null;
  private profileImage_: Url|null;
  private isCameraPresent_: boolean;
  private isCustomizationSelectorsEnabled_: boolean;
  private cameraMode_: AvatarCameraMode|null;
  private image_: UserImage|null;
  private lastExternalUserImageUrl_: Url|null;
  private options_: Option[];

  override connectedCallback() {
    super.connectedCallback();
    this.watch<AvatarListElement['defaultUserImages_']>(
        'defaultUserImages_', state => state.user.defaultUserImages);
    this.watch<AvatarListElement['profileImage_']>(
        'profileImage_', state => state.user.profileImage);
    this.watch<AvatarListElement['isCameraPresent_']>(
        'isCameraPresent_', state => state.user.isCameraPresent);
    this.watch<AvatarListElement['image_']>(
        'image_', state => state.user.image);
    this.watch<AvatarListElement['lastExternalUserImageUrl_']>(
        'lastExternalUserImageUrl_', selectLastExternalUserImageUrl);
    this.updateFromStore();
    fetchDefaultUserImages(getUserProvider(), this.getStore());
    window.addEventListener('offline', this.onAvatarNetworkError_);
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    window.removeEventListener('offline', this.onAvatarNetworkError_);
  }

  /** Invoked to update |options_|. */
  private updateOptions_(
      isCameraPresent: AvatarListElement['isCameraPresent_'],
      profileImage: AvatarListElement['profileImage_'],
      lastExternalUserImageUrl: AvatarListElement['lastExternalUserImageUrl_'],
      defaultUserImages: AvatarListElement['defaultUserImages_']) {
    const options: Option[] = [];
    if (this.isCustomizationSelectorsEnabled_) {
      if (isCameraPresent) {
        // Add camera and video options.
        options.push({
          id: OptionId.OPEN_CAMERA,
          class: 'avatar-button-container',
          imgSrc: '',
          icon: 'personalization:camera',
          title: this.i18n('takeWebcamPhoto'),
        });
        options.push({
          id: OptionId.OPEN_VIDEO,
          class: 'avatar-button-container',
          icon: 'personalization:loop',
          title: this.i18n('takeWebcamVideo'),
        });
      }
      // Add open folder option.
      options.push({
        id: OptionId.OPEN_FOLDER,
        class: 'avatar-button-container',
        icon: 'personalization:folder',
        title: this.i18n('chooseAFile'),
      });
      if (profileImage && profileImage.url) {
        options.push({
          id: OptionId.PROFILE_IMAGE,
          class: 'image-container',
          imgSrc: profileImage.url,
          icon: 'personalization-shared:circle-checkmark',
          title: this.i18n('googleProfilePhoto'),
        });
      }
      if (lastExternalUserImageUrl) {
        options.push({
          id: OptionId.LAST_EXTERNAL_IMAGE,
          class: 'image-container',
          imgSrc: lastExternalUserImageUrl.url,
          icon: 'personalization-shared:circle-checkmark',
          title: this.i18n('lastExternalImageTitle'),
        });
      }
    }
    if (isNonEmptyArray(defaultUserImages)) {
      defaultUserImages.forEach(defaultImage => {
        options.push({
          id: `defaultUserImage-${defaultImage.index}`,
          class: 'image-container',
          imgSrc: defaultImage.url.url,
          icon: 'personalization-shared:circle-checkmark',
          title: mojoString16ToString(defaultImage.title),
          defaultImageIndex: defaultImage.index,
        });
      });
    }

    const activeElement = this.shadowRoot!.activeElement;

    this.updateList(
        /*propertyPath=*/ 'options_',
        /*identityGetter=*/
        (option: Option) => {
          switch (option.id) {
            // LAST_EXTERNAL_IMAGE needs to use imgSrc instead of id. Otherwise
            // iron-list will not update properly when LAST_EXTERNAL_IMAGE
            // changes, i.e. when user selects a new file from disk.
            case OptionId.LAST_EXTERNAL_IMAGE:
              return option.imgSrc!;
            default:
              return option.id;
          }
        },
        /*newList=*/ options,
        /*identityBasedUpdate=*/ true,
    );

    if (activeElement instanceof HTMLElement) {
      // Restore focus to previously selected element after list update.
      activeElement.focus();
    }
  }

  private onLastExternalUserImageUrlChanged_(_: Url|null, old: Url|null) {
    if (old && old.url && old.url.startsWith('blob:')) {
      URL.revokeObjectURL(old.url);
    }
  }

  private onOptionSelected_(e: Event) {
    if (!isSelectionEvent(e)) {
      return;
    }
    const divElement = e.currentTarget as HTMLDivElement;
    const id = divElement.id;
    switch (id) {
      case OptionId.OPEN_CAMERA:
        this.openCamera_(e);
        break;
      case OptionId.OPEN_VIDEO:
        this.openVideo_(e);
        break;
      case OptionId.OPEN_FOLDER:
        this.onSelectImageFromDisk_(e);
        break;
      case OptionId.PROFILE_IMAGE:
        this.onSelectProfileImage_(e);
        break;
      case OptionId.LAST_EXTERNAL_IMAGE:
        this.onSelectLastExternalUserImage_(e);
        break;
      default:
        this.onSelectDefaultImage_(e);
        break;
    }
  }

  /**
   * Called when there's an image load error.
   *
   * The most common case would be when trying to load default avatars
   * from gstatic resources for the first time while the device is offline.
   */
  private onImgError_(e: Event) {
    const divElement = e.currentTarget as HTMLDivElement;
    divElement.setAttribute('hidden', 'true');
  }

  /**
   * Called when (1) avatar images fail to load, (2) the device goes
   * offline while the avatar picker window is open, or (3) the user
   * tries to select an avatar while the device is offline.
   */
  private onAvatarNetworkError_ = () => {
    this.dispatch(setErrorAction({
      id: 'AvatarList',
      message: this.i18n('avatarNetworkError'),
      dismiss: {
        message: this.i18n('dismiss'),
      },
    }));
  };

  private getImageClassForOption_(option: Option) {
    if (option.imgSrc) {
      return '';
    }
    return 'hidden';
  }

  private onSelectDefaultImage_(event: Event) {
    if (!window.navigator.onLine) {
      this.onAvatarNetworkError_();
      return;
    }

    if (!isSelectionEvent(event)) {
      return;
    }

    const id = (event.currentTarget as HTMLElement).dataset['id'];
    if (!id) {
      return;
    }

    const index = parseInt(id, 10);
    getUserProvider().selectDefaultImage(index);
  }

  private onSelectProfileImage_(event: Event) {
    if (!isSelectionEvent(event)) {
      return;
    }

    getUserProvider().selectProfileImage();
  }

  private onSelectLastExternalUserImage_(event: Event) {
    if (!isSelectionEvent(event)) {
      return;
    }
    getUserProvider().selectLastExternalUserImage();
  }

  private openCamera_(event: Event) {
    if (!isSelectionEvent(event)) {
      return;
    }
    assert(this.isCameraPresent_, 'Camera needed to record an image');
    this.cameraMode_ = AvatarCameraMode.CAMERA;
  }

  private openVideo_(event: Event) {
    if (!isSelectionEvent(event)) {
      return;
    }
    assert(this.isCameraPresent_, 'Camera needed to record a video');
    this.cameraMode_ = AvatarCameraMode.VIDEO;
  }

  private onIsCameraPresentChanged_(value: boolean) {
    // Potentially hide camera UI if the camera has become unavailable.
    if (!value) {
      this.cameraMode_ = null;
    }
  }

  private onSelectImageFromDisk_(event: Event) {
    if (!isSelectionEvent(event)) {
      return;
    }

    getUserProvider().selectImageFromDisk();
  }

  private onCameraClosed_() {
    this.cameraMode_ = null;
  }

  private getAriaIndex_(i: number): number {
    return i + 1;
  }

  private getAriaSelected_(option: Option, image: UserImage|null): string {
    if (!option) {
      return 'false';
    }
    switch (option.id) {
      case OptionId.OPEN_CAMERA:
      case OptionId.OPEN_VIDEO:
      case OptionId.OPEN_FOLDER:
        return 'false';
      case OptionId.PROFILE_IMAGE:
        return (!!image && !!image.profileImage).toString();
      case OptionId.LAST_EXTERNAL_IMAGE:
        return (!!image && !!image.externalImage).toString();
      default:
        // Handle default user image.
        assert(isDefaultOption(option));
        return (!!image && !!image.defaultImage &&
                image.defaultImage.index === option.defaultImageIndex)
            .toString();
    }
  }

  private getOptionInnerContainerClass_(option: Option, image: UserImage|null):
      string {
    const defaultClass = option ? option.class : 'image-container';
    return this.getAriaSelected_(option, image) === 'true' ?
        `${defaultClass} tast-selected-${camelToKebab(option.id)}` :
        defaultClass;
  }

  /**
   * Creates style string with static background image url for default
   * avatar images. Static image loads faster and will provide a
   * smooth experience when the animated image completes loading.
   */
  private getImgBackgroundStyle_(url: string, defaultImageIndex: number|null):
      string {
    // If the image is a default avatar loaded from gstatic resources,
    // return a static encoded background image.
    if (defaultImageIndex) {
      assert(
          !url.startsWith('chrome://image/'),
          'The URL shouldn\'t be sanitized');
      return `background-image: url('${
          getAvatarUrl(url, /*staticEncode=*/ true)}')`;
    }
    return '';
  }

  private getAvatarUrl_(url: string): string {
    return getAvatarUrl(url);
  }
}

customElements.define(AvatarListElement.is, AvatarListElement);