chromium/ash/webui/common/resources/sea_pen/sea_pen_recent_wallpapers_element.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.

/**
 * @fileoverview A polymer component that displays the SeaPen recently used
 * wallpapers.
 */

import 'chrome://resources/ash/common/personalization/common.css.js';
import 'chrome://resources/ash/common/personalization/wallpaper.css.js';
import 'chrome://resources/ash/common/sea_pen/sea_pen.css.js';
import 'chrome://resources/ash/common/sea_pen/sea_pen_icons.html.js';
import 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';

import {AnchorAlignment} from 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import {WallpaperGridItemSelectedEvent} from 'chrome://resources/ash/common/personalization/wallpaper_grid_item_element.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 {afterNextRender} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {SeaPenImageId} from './constants.js';
import {isSeaPenTextInputEnabled} from './load_time_booleans.js';
import {RecentSeaPenThumbnailData, SeaPenThumbnail} from './sea_pen.mojom-webui.js';
import {deleteRecentSeaPenImage, fetchRecentSeaPenData, getSeaPenThumbnails, selectRecentSeaPenImage} from './sea_pen_controller.js';
import {getSeaPenProvider} from './sea_pen_interface_provider.js';
import {logRecentImageActionMenuItemClick, RecentImageActionMenuItem} from './sea_pen_metrics_logger.js';
import {getTemplate} from './sea_pen_recent_wallpapers_element.html.js';
import {SeaPenRouterElement} from './sea_pen_router_element.js';
import {WithSeaPenStore} from './sea_pen_store.js';
import {getUserVisibleQuery, isActiveSeaPenQuery, isImageDataUrl, isNonEmptyArray, isPersonalizationApp, isSeaPenImageId} from './sea_pen_utils.js';

export class SeaPenRecentImageDeleteEvent extends CustomEvent<null> {
  static readonly EVENT_NAME = 'sea-pen-recent-image-delete';

  constructor() {
    super(
        SeaPenRecentImageDeleteEvent.EVENT_NAME,
        {
          bubbles: true,
          composed: true,
          detail: null,
        },
    );
  }
}

export class SeaPenRecentWallpapersElement extends WithSeaPenStore {
  static get is() {
    return 'sea-pen-recent-wallpapers';
  }

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

  static get properties() {
    return {
      recentImages_: {
        type: Array,
        observer: 'onRecentImagesChanged_',
      },

      /** Mapping of recent Sea Pen image path to its data. */
      recentImageData_: Object,

      /**
         Mapping of recent Sea Pen image path to data loading status (boolean).
       */
      recentImageDataLoading_: Object,

      recentImagesToDisplay_: {
        type: Array,
        value: [],
      },

      currentShowWallpaperInfoDialog_: {
        type: Number,
        value: null,
      },

      currentSelected_: Number,

      pendingSelected_: Object,

      isSeaPenTextInputEnabled_: {
        type: Boolean,
        value() {
          return isSeaPenTextInputEnabled();
        },
      },
    };
  }

  private recentImages_: SeaPenImageId[]|null;
  private recentImageData_:
      Record<SeaPenImageId, RecentSeaPenThumbnailData|null>;
  private recentImageDataLoading_: Record<SeaPenImageId, boolean>;
  private recentImagesToDisplay_: SeaPenImageId[];
  private currentShowWallpaperInfoDialog_: number|null;
  private currentSelected_: SeaPenImageId|null;
  private pendingSelected_: SeaPenImageId|SeaPenThumbnail|null;
  private isSeaPenTextInputEnabled_: boolean;

  static get observers() {
    return ['onRecentImageLoaded_(recentImageData_, recentImageDataLoading_)'];
  }

  override connectedCallback() {
    super.connectedCallback();
    this.watch<SeaPenRecentWallpapersElement['recentImages_']>(
        'recentImages_', state => state.recentImages);
    this.watch<SeaPenRecentWallpapersElement['recentImageData_']>(
        'recentImageData_', state => state.recentImageData);
    this.watch<SeaPenRecentWallpapersElement['recentImageDataLoading_']>(
        'recentImageDataLoading_', state => state.loading.recentImageData);
    this.watch<SeaPenRecentWallpapersElement['currentSelected_']>(
        'currentSelected_', state => state.currentSelected);
    this.watch<SeaPenRecentWallpapersElement['pendingSelected_']>(
        'pendingSelected_', state => state.pendingSelected);
    this.updateFromStore();
    fetchRecentSeaPenData(getSeaPenProvider(), this.getStore());
  }

  /**
   * Sets `recentImagesToDisplay` when a new set of recent Sea Pen images
   * loads.
   */
  private onRecentImagesChanged_(recentImages: SeaPenImageId[]|null) {
    this.recentImagesToDisplay_ = (recentImages || []).filter(id => {
      if (this.recentImageDataLoading_[id] === false) {
        return isImageDataUrl(this.recentImageData_[id]?.url);
      }
      return true;
    });
  }

  /**
   * Called each time a new recent Sea Pen image data is loaded. Removes images
   * from the list of displayed images if it has failed to load.
   */
  private onRecentImageLoaded_(
      recentImageData: Record<SeaPenImageId, RecentSeaPenThumbnailData|null>,
      recentImageDataLoading: Record<SeaPenImageId, boolean>) {
    if (!recentImageData || !recentImageDataLoading) {
      return;
    }

    // Iterate backwards in case we need to splice to remove from
    // `recentImagesToDisplay` while iterating.
    for (let i = this.recentImagesToDisplay_.length - 1; i >= 0; i--) {
      const id = this.recentImagesToDisplay_[i];
      const data = recentImageData[id];
      const validData = isImageDataUrl(data?.url);
      const failed = recentImageDataLoading[id] === false && !validData;
      if (failed) {
        this.splice('recentImagesToDisplay_', i, 1);
      }
    }
  }

  private isRecentImageLoading_(
      recentImage: SeaPenImageId|null,
      recentImageDataLoading: Record<SeaPenImageId, boolean>): boolean {
    if (!recentImage || !recentImageDataLoading) {
      return true;
    }
    // If key is not present, then loading has not yet started. Still show a
    // loading tile in this case.
    return !recentImageDataLoading.hasOwnProperty(recentImage) ||
        recentImageDataLoading[recentImage] === true;
  }

  private getRecentImageUrl_(
      recentImage: SeaPenImageId,
      recentImageData: Record<SeaPenImageId, RecentSeaPenThumbnailData|null>,
      recentImageDataLoading: Record<SeaPenImageId, boolean>): Url|null {
    if (!recentImage ||
        this.isRecentImageLoading_(recentImage, recentImageDataLoading)) {
      return null;
    }
    const data = recentImageData[recentImage];
    if (!isImageDataUrl(data?.url)) {
      return {url: ''};
    }
    return data.url;
  }

  private getWallpaperInfoPromptMessage_(
      recentImage: SeaPenImageId,
      recentImageData: Record<SeaPenImageId, RecentSeaPenThumbnailData|null>,
      recentImageDataLoading: Record<SeaPenImageId, boolean>): string|null {
    if (!recentImage ||
        this.isRecentImageLoading_(recentImage, recentImageDataLoading)) {
      return null;
    }

    const data = recentImageData[recentImage];
    if (!data || !data.imageInfo || !data.imageInfo.query) {
      return null;
    }

    const title = isPersonalizationApp() ? 'seaPenAboutDialogPrompt' :
                                           'vcBackgroundAboutDialogPrompt';
    return this.i18n(title, getUserVisibleQuery(data.imageInfo.query));
  }

  private getWallpaperInfoDateMessage_(
      recentImage: SeaPenImageId,
      recentImageData: Record<SeaPenImageId, RecentSeaPenThumbnailData|null>,
      recentImageDataLoading: Record<SeaPenImageId, boolean>): string|null {
    if (!recentImage ||
        this.isRecentImageLoading_(recentImage, recentImageDataLoading)) {
      return null;
    }

    const data = recentImageData[recentImage];
    if (!data || !data.imageInfo || !data.imageInfo.creationTime) {
      return null;
    }

    return this.i18n(
        'seaPenAboutDialogDate',
        mojoString16ToString(data.imageInfo.creationTime));
  }

  private getAboutDialogTitle_(): string {
    return isPersonalizationApp() ? this.i18n('seaPenAboutDialogTitle') :
                                    this.i18n('vcBackgroundAboutDialogTitle');
  }

  private getRecentPoweredByGoogleMessage_(): string {
    return isPersonalizationApp() ?
        this.i18n('seaPenRecentWallpapersHeading') :
        this.i18n('vcBackgroundRecentWallpapersHeading');
  }

  private getAriaLabel_(
      image: SeaPenImageId,
      recentImageData: Record<SeaPenImageId, RecentSeaPenThumbnailData|null>,
      recentImageDataLoading: Record<SeaPenImageId, boolean>): string {
    if (!image || this.isRecentImageLoading_(image, recentImageDataLoading)) {
      return this.i18n('ariaLabelLoading');
    }

    const data = recentImageData[image];
    if (!data || !data.imageInfo || !data.imageInfo.query) {
      return '';
    }

    return getUserVisibleQuery(data.imageInfo.query);
  }

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

  private shouldShowRecentlyUsedWallpapers_(recentImagesToDisplay:
                                                SeaPenImageId[]|null) {
    return isNonEmptyArray(recentImagesToDisplay);
  }

  private isRecentImageSelected_(
      id: SeaPenImageId|null, currentSelected: SeaPenImageId|null,
      pendingSelected: SeaPenImageId|SeaPenThumbnail|null) {
    if (!isSeaPenImageId(id)) {
      return false;
    }

    if (pendingSelected !== null) {
      // User just clicked on a recent image.
      if (isSeaPenImageId(pendingSelected)) {
        return id === pendingSelected;
      } else {
        // |pendingSelected| is a SeaPenThumbnail.
        return id === pendingSelected.id;
      }
    }

    return id === currentSelected;
  }

  private onRecentImageSelected_(event: WallpaperGridItemSelectedEvent&
                                 {model: {image: SeaPenImageId}}) {
    assert(
        isSeaPenImageId(event.model.image),
        'recent Sea Pen image is a positive number');
    selectRecentSeaPenImage(
        event.model.image, getSeaPenProvider(), this.getStore());
  }

  private onClickMenuIcon_(e: Event) {
    const targetElement = e.currentTarget as HTMLElement;
    const menuIconContainerRect = targetElement.getBoundingClientRect();
    const config = {
      top: menuIconContainerRect.top -
          8,  // 8px is the padding of .menu-icon-container
      left: menuIconContainerRect.left - menuIconContainerRect.width / 2,
      height: menuIconContainerRect.height,
      width: menuIconContainerRect.width,
      anchorAlignmentX: AnchorAlignment.AFTER_END,
      anchorAlignmentY: AnchorAlignment.BEFORE_START,
    };
    const id = targetElement.dataset['id'];
    if (id !== undefined) {
      const index = parseInt(id, 10);
      const menuElement =
          this.shadowRoot!.querySelectorAll('cr-action-menu')[index];
      menuElement!.showAtPosition(config);
      // focus on the top menu item first.
      const menuItems = menuElement!.querySelectorAll<HTMLElement>(
          '.dropdown-item:not([hidden]):not(.more-like-this-option)');
      menuItems![0].focus();
    }
  }

  private onClickCreateMore_(event: Event&{
    model: {index: number, image: SeaPenImageId},
  }) {
    assert(
        isSeaPenImageId(event.model.image),
        'selected Sea Pen image is a positive number');
    const image = event.model.image;
    if (!image ||
        this.isRecentImageLoading_(image, this.recentImageDataLoading_)) {
      return;
    }

    const seaPenQuery = this.recentImageData_[image]?.imageInfo?.query;
    if (!seaPenQuery) {
      return;
    }

    const templateId =
        seaPenQuery.textQuery ? 'Query' : seaPenQuery.templateQuery?.id;
    // Log metrics for 'Create More' button click.
    logRecentImageActionMenuItemClick(
        !!seaPenQuery.textQuery, RecentImageActionMenuItem.CREATE_MORE);
    // Route to the results page and search thumbnails for the Sea Pen query.
    SeaPenRouterElement.instance().selectSeaPenTemplate(templateId);
    getSeaPenThumbnails(seaPenQuery, getSeaPenProvider(), this.getStore());
  }

  private async onClickDeleteWallpaper_(event: Event&{
    model: {index: number, image: SeaPenImageId},
  }) {
    // TODO (b/315069374): confirm if currently set Sea Pen wallpaper can be
    // removed.
    assert(
        isSeaPenImageId(event.model.image),
        'selected Sea Pen image is a positive number');
    const index = event.model.index;
    const isLastOrOnlyImage = this.recentImagesToDisplay_.length === 1 ||
        index === this.recentImagesToDisplay_.length - 1;

    await deleteRecentSeaPenImage(
        event.model.image, getSeaPenProvider(), this.getStore());

    // Log metrics for 'Delete' button click.
    const isTextQuery =
        !!this.recentImageData_[event.model.image]?.imageInfo?.query?.textQuery;
    logRecentImageActionMenuItemClick(
        isTextQuery, RecentImageActionMenuItem.DELETE);
    this.closeAllActionMenus_();

    // If the deleted image is the last image or the only image in recent
    // images, focus on the first template.
    if (isLastOrOnlyImage) {
      this.dispatchEvent(new SeaPenRecentImageDeleteEvent());
      return;
    }

    // Otherwise, focus on the next image after deletion.
    afterNextRender(this, () => {
      const recentImageContainers =
          this.shadowRoot!.querySelectorAll<HTMLElement>(
              '.recent-image-container:not([hidden])');
      const recentImage =
          recentImageContainers![index].querySelector<HTMLElement>(
              '.sea-pen-image');
      recentImage!.setAttribute('tabindex', '0');
      recentImage!.focus();
      const menuIconButton =
          recentImageContainers![index].querySelector<HTMLElement>(
              '.menu-icon-button');
      menuIconButton!.setAttribute('tabindex', '0');
    });
  }

  private onClickWallpaperInfo_(event: Event&{
    model: {index: number, image: SeaPenImageId},
  }) {
    this.currentShowWallpaperInfoDialog_ = event.model.index;
    // Log metrics for 'About' button click.
    const isTextQuery =
        !!this.recentImageData_[event.model.image]?.imageInfo?.query?.textQuery;
    logRecentImageActionMenuItemClick(
        isTextQuery, RecentImageActionMenuItem.ABOUT);
    this.closeAllActionMenus_();
  }

  private closeAllActionMenus_() {
    const menuElements = this.shadowRoot!.querySelectorAll('cr-action-menu');
    menuElements.forEach(menuElement => {
      menuElement.close();
    });
  }

  private shouldShowCreateMoreButton_(
      recentImage: SeaPenImageId,
      recentImageData: Record<SeaPenImageId, RecentSeaPenThumbnailData|null>,
      recentImageDataLoading: Record<SeaPenImageId, boolean>): boolean {
    if (!this.isSeaPenTextInputEnabled_ || !recentImage ||
        this.isRecentImageLoading_(recentImage, recentImageDataLoading)) {
      return false;
    }

    const data = recentImageData[recentImage];
    return isActiveSeaPenQuery(data?.imageInfo?.query);
  }

  private shouldShowWallpaperInfoButton_(
      recentImage: SeaPenImageId,
      recentImageData: Record<SeaPenImageId, RecentSeaPenThumbnailData|null>,
      recentImageDataLoading: Record<SeaPenImageId, boolean>): boolean {
    if (!recentImage ||
        this.isRecentImageLoading_(recentImage, recentImageDataLoading)) {
      return false;
    }

    const data = recentImageData[recentImage];
    return !!data && !!data.imageInfo && !!data.imageInfo.creationTime &&
        !!data.imageInfo.query;
  }

  private shouldShowWallpaperInfoDialog_(
      i: number, currentShowWallpaperInfoDialog: number|null): boolean {
    return currentShowWallpaperInfoDialog === i;
  }

  private onCloseDialog_() {
    const menuId = this.currentShowWallpaperInfoDialog_;
    this.currentShowWallpaperInfoDialog_ = null;
    // after the dialog is closed, focus on the last target menu button.
    afterNextRender(this, () => {
      const menuButtons =
          this.shadowRoot!.querySelectorAll<HTMLElement>('.menu-icon-button');
      if (menuId !== null && menuButtons.length > menuId + 1) {
        menuButtons[menuId]!.focus();
      }
    });
  }
}

customElements.define(
    SeaPenRecentWallpapersElement.is, SeaPenRecentWallpapersElement);