chromium/ash/webui/personalization_app/resources/js/wallpaper/wallpaper_collections_element.ts

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview
 * Polymer element that fetches and displays a list of WallpaperCollection
 * objects.
 */

import 'chrome://resources/ash/common/personalization/common.css.js';
import 'chrome://resources/ash/common/personalization/wallpaper.css.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';

import {WallpaperGridItemSelectedEvent} from 'chrome://resources/ash/common/personalization/wallpaper_grid_item_element.js';
import {isManagedSeaPenEnabled, isSeaPenEnabled, isSeaPenTextInputEnabled} from 'chrome://resources/ash/common/sea_pen/load_time_booleans.js';
import {cleanUpSeaPenQueryStates} from 'chrome://resources/ash/common/sea_pen/sea_pen_controller.js';
import {getSeaPenStore} from 'chrome://resources/ash/common/sea_pen/sea_pen_store.js';
import {isImageDataUrl, isNonEmptyArray} from 'chrome://resources/ash/common/sea_pen/sea_pen_utils.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {FilePath} from 'chrome://resources/mojo/mojo/public/mojom/base/file_path.mojom-webui.js';
import {Url} from 'chrome://resources/mojo/url/mojom/url.mojom-webui.js';
import {IronListElement} from 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import {afterNextRender} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {GooglePhotosEnablementState, WallpaperCollection, WallpaperImage} from '../../personalization_app.mojom-webui.js';
import {isGooglePhotosIntegrationEnabled, isTimeOfDayWallpaperEnabled} from '../load_time_booleans.js';
import {Paths, PersonalizationRouterElement} from '../personalization_router_element.js';
import {WithPersonalizationStore} from '../personalization_store.js';
import {getCountText, isSelectionEvent} from '../utils.js';

import {DefaultImageSymbol, kDefaultImageSymbol, kMaximumLocalImagePreviews} from './constants.js';
import {getLoadingPlaceholderAnimationDelay, getLoadingPlaceholders, getPathOrSymbol} from './utils.js';
import {getTemplate} from './wallpaper_collections_element.html.js';
import {fetchGooglePhotosEnabled, fetchLocalData, getDefaultImageThumbnail, initializeBackdropData} from './wallpaper_controller.js';
import {getWallpaperProvider} from './wallpaper_interface_provider.js';

const kGooglePhotosCollectionId = 'google_photos_';
const kLocalCollectionId = 'local_';
const kSeaPenId = 'sea_pen_';
const kSeaPenPromptingId = 'sea_pen_prompting_';

enum TileType {
  IMAGE_GOOGLE_PHOTOS = 'image_google_photos',
  IMAGE_LOCAL = 'image_local',
  IMAGE_ONLINE = 'image_online',
  LOADING = 'loading',
  SEA_PEN_TEMPLATES = 'sea_pen_templates',
  SEA_PEN_PROMPTING = 'sea_pen_prompting',
}

interface LoadingTile {
  type: TileType.LOADING;
  id: string;
}

interface GooglePhotosTile {
  disabled: boolean;
  id: typeof kGooglePhotosCollectionId;
  name: string;
  type: TileType.IMAGE_GOOGLE_PHOTOS;
  preview: [Url];
}

interface SeaPenPromptingTile {
  disabled: boolean;
  id: typeof kSeaPenPromptingId;
  name: string;
  preview: [Url];
  type: TileType.SEA_PEN_PROMPTING;
}

interface SeaPenTemplatesTile {
  disabled: boolean;
  id: typeof kSeaPenId;
  name: string;
  preview: [Url];
  type: TileType.SEA_PEN_TEMPLATES;
}

interface LocalTile {
  count: string;
  disabled: boolean;
  id: typeof kLocalCollectionId;
  name: string;
  preview: Url[];
  type: TileType.IMAGE_LOCAL;
}

interface OnlineTile {
  count: string;
  disabled: boolean;
  id: string;
  info: string;
  name: string;
  preview: Url[];
  type: TileType.IMAGE_ONLINE;
}

type Tile = LoadingTile|GooglePhotosTile|LocalTile|OnlineTile|
    SeaPenPromptingTile|SeaPenTemplatesTile;

// "regular" backdrop collections are displayed differently than the special
// "timeOfDay" wallpaper collection. Split them to make them easier to handle.
// The special "timeOfDay" collection may not exist depending on the device and
// features enabled.
interface SplitCollections {
  regular: WallpaperCollection[];
  timeOfDay: WallpaperCollection|null;
}

/**
 * Before switching entirely to WallpaperGridItem, have to deal with a mix of
 * keyboard and mouse events and a custom WallpaperGridItemSelected event.
 */
type OnCollectionSelectedEvent =
    (MouseEvent|KeyboardEvent|WallpaperGridItemSelectedEvent)&
    {model: {item: Tile}};

function collectionsError(
    collections: WallpaperCollection[]|null,
    collectionsLoading: boolean): boolean {
  return !collectionsLoading && !isNonEmptyArray(collections);
}

function localImagesError(
    localImages: Array<FilePath|DefaultImageSymbol>|null,
    localImagesLoading: boolean): boolean {
  return !localImagesLoading && !isNonEmptyArray(localImages);
}

function hasError(
    collections: WallpaperCollection[]|null, collectionsLoading: boolean,
    localImages: Array<FilePath|DefaultImageSymbol>|null,
    localImagesLoading: boolean): boolean {
  return localImagesError(localImages, localImagesLoading) &&
      collectionsError(collections, collectionsLoading);
}

/** Returns the tile to display for the Google Photos collection. */
function getGooglePhotosTile(enablementState: GooglePhotosEnablementState):
    GooglePhotosTile {
  return {
    disabled: enablementState !== GooglePhotosEnablementState.kEnabled,
    id: kGooglePhotosCollectionId,
    name: loadTimeData.getString('googlePhotosLabel'),
    type: TileType.IMAGE_GOOGLE_PHOTOS,
    preview: [{url: 'chrome://personalization/images/google_photos.svg'}],
  };
}

function getImages(
    localImages: Array<FilePath|DefaultImageSymbol>,
    localImageData: Record<string|DefaultImageSymbol, Url>): Url[] {
  if (!localImageData || !Array.isArray(localImages)) {
    return [];
  }
  const result = [];
  for (const image of localImages) {
    const key = getPathOrSymbol(image);
    const data = localImageData[key];
    if (isImageDataUrl(data)) {
      result.push(data);
    }
    // Add at most |kMaximumLocalImagePreviews| thumbnail urls.
    if (result.length >= kMaximumLocalImagePreviews) {
      break;
    }
  }
  return result;
}

/**
 * A common display format between local images and WallpaperCollection.
 * Get the first displayable image with data from the list of possible images.
 */
function getLocalTile(
    localImages: Array<FilePath|DefaultImageSymbol>|null,
    localImagesLoading: boolean,
    localImageData: Record<FilePath['path']|DefaultImageSymbol, Url>):
    LocalTile|LoadingTile {
  if (localImagesLoading) {
    return {type: TileType.LOADING, id: kLocalCollectionId};
  }

  if (!isNonEmptyArray(localImages)) {
    // TODO(b/282050032): After Jelly is launched, remove the preview image.
    return {
      count: getCountText(0),
      disabled: true,
      id: kLocalCollectionId,
      name: loadTimeData.getString('myImagesLabel'),
      preview: [{url: 'chrome://personalization/images/no_images.svg'}],
      type: TileType.IMAGE_LOCAL,
    };
  }

  const imagesToDisplay = getImages(localImages, localImageData);

  // Count all images that failed to load and subtract them from "My Images"
  // count.
  const failureCount = Object.values(localImageData).reduce((result, next) => {
    return !isImageDataUrl(next) ? result + 1 : result;
  }, 0);

  const successCount = localImages.length - failureCount;

  return {
    count: getCountText(successCount),
    disabled: successCount <= 0,
    id: kLocalCollectionId,
    name: loadTimeData.getString('myImagesLabel'),
    preview: imagesToDisplay,
    type: TileType.IMAGE_LOCAL,
  };
}

function getOnlineTile(
    collection: WallpaperCollection, imageCount: number|null): OnlineTile {
  return {
    count: getCountText(imageCount || 0),
    // If `imageCount` is null or 0, this collection failed to load and the user
    // cannot select it.
    disabled: !imageCount,
    id: collection.id,
    info: collection.descriptionContent,
    name: collection.name,
    preview: collection.previews,
    type: TileType.IMAGE_ONLINE,
  };
}

function getSeaPenPromptingTile(): SeaPenPromptingTile {
  return {
    disabled: false,
    id: kSeaPenPromptingId,
    name: 'Sea Pen Prompting',
    type: TileType.SEA_PEN_PROMPTING,
    preview: [{
      url:
          'chrome://resources/ash/common/sea_pen/sea_pen_images/sea_pen_freeform.jpg',
    }],
  };
}

function getSeaPenTemplatesTile(): SeaPenTemplatesTile {
  return {
    disabled: !isManagedSeaPenEnabled(),
    id: kSeaPenId,
    name: 'Sea Pen',
    type: TileType.SEA_PEN_TEMPLATES,
    preview: [{
      url:
          'chrome://resources/ash/common/sea_pen/sea_pen_images/sea_pen_tile.jpg',
    }],
  };
}

function getTemporaryBackdropCollectionId(index: number) {
  return `backdrop_collection_${index}`;
}

function isTimeOfDay({id}: WallpaperCollection|Tile): boolean {
  return id === loadTimeData.getString('timeOfDayWallpaperCollectionId');
}

export interface WallpaperCollectionsElement {
  $: {
    grid: IronListElement,
  };
}

export class WallpaperCollectionsElement extends WithPersonalizationStore {
  static get is() {
    return 'wallpaper-collections';
  }

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

  static get properties() {
    return {
      /** Hidden state of this element. Used to notify of visibility changes. */
      hidden: {
        type: Boolean,
        reflectToAttribute: true,
        observer: 'onHiddenChanged_',
      },

      collections_: {
        type: Array,
        observer: 'onCollectionsChanged_',
      },

      /**
       * Wallpaper collections split out into "regular" collections, and the
       * special time of day collection.
       */
      splitCollections_: Object,

      images_: Object,

      imagesLoading_: Object,

      /**
       * Whether the user is allowed to access Google Photos.
       */
      googlePhotosEnabled_: {
        type: Number,
        observer: 'onGooglePhotosEnabledChanged_',
      },

      /**
       * Mapping of collection id to number of images. Loads in progressively
       * after collections_.
       */
      imageCounts_: {
        type: Object,
        computed: 'computeImageCounts_(images_, imagesLoading_)',
      },

      localImages_: Array,

      localImagesLoading_: Boolean,

      /**
       * Stores a mapping of local image id to thumbnail data.
       */
      localImageData_: {
        type: Object,
        value: {},
      },

      /**
       * Stores a list of promoted tiles, including Time of Day and SeaPen.
       */
      promotedTiles_: {
        type: Array,
        value() {
          const tiles = [];
          if (isSeaPenTextInputEnabled()) {
            tiles.push(
                {type: TileType.LOADING, id: kSeaPenPromptingId} as
                LoadingTile);
          }
          tiles.push({type: TileType.LOADING, id: kSeaPenId} as LoadingTile);
          if (isTimeOfDayWallpaperEnabled()) {
            tiles.push({
              type: TileType.LOADING,
              id: loadTimeData.getString('timeOfDayWallpaperCollectionId'),
            } as LoadingTile);
          }
          return tiles;
        },
      },

      /**
       * List of tiles to be displayed to the user. The Time of Day tile is in
       * promotedTiles_ when SeaPen is enabled, and in tiles_ when SeaPen is
       * disabled.
       */
      tiles_: {
        type: Array,
        value() {
          // Fill the view with loading tiles. Will be adjusted to the correct
          // number of tiles when collections are received.
          const placeholders = getLoadingPlaceholders<LoadingTile>(
              () => ({type: TileType.LOADING, id: ''}));

          let currentIndex = 0;
          // Time of day tile.
          if (isTimeOfDayWallpaperEnabled() && !isSeaPenEnabled()) {
            placeholders[currentIndex].id =
                loadTimeData.getString('timeOfDayWallpaperCollectionId');
            currentIndex++;
          }

          // Local images tile.
          placeholders[currentIndex].id = kLocalCollectionId;
          currentIndex++;

          // Google Photos tile.
          if (isGooglePhotosIntegrationEnabled()) {
            placeholders[currentIndex].id = kGooglePhotosCollectionId;
            currentIndex++;
          }

          // The rest of the backdrop tiles. Actual number will be adjusted once
          // collections are received. The actual id is not important as long as
          // they are unique.
          const firstBackdropIndex = currentIndex;
          while (currentIndex < placeholders.length) {
            placeholders[currentIndex].id = getTemporaryBackdropCollectionId(
                currentIndex - firstBackdropIndex);
            currentIndex++;
          }

          return placeholders;
        },
      },

      hasError_: Boolean,

      isSeaPenEnabled_: {
        type: Boolean,
        value() {
          return isSeaPenEnabled();
        },
      },
    };
  }

  override hidden: boolean;
  private collections_: WallpaperCollection[]|null;
  private splitCollections_: SplitCollections|null;
  private images_: Record<string, WallpaperImage[]|null>;
  private imagesLoading_: Record<string, boolean>;
  private imageCounts_: Record<string, number|null>;
  private googlePhotosEnabled_: GooglePhotosEnablementState|undefined;
  private isSeaPenEnabled_: boolean;
  private localImages_: Array<FilePath|DefaultImageSymbol>|null;
  private localImagesLoading_: boolean;
  private localImageData_: Record<string|DefaultImageSymbol, Url>;
  private tiles_: Tile[];
  private promotedTiles_: Tile[];
  private hasError_: boolean;

  static get observers() {
    return [
      'onLocalImagesChanged_(localImages_, localImagesLoading_, localImageData_)',
      'onCollectionLoaded_(splitCollections_, imageCounts_)',
    ];
  }

  override connectedCallback() {
    super.connectedCallback();
    this.watch<WallpaperCollectionsElement['hasError_']>(
        'hasError_',
        state => hasError(
            state.wallpaper.backdrop.collections,
            state.wallpaper.loading.collections, state.wallpaper.local.images,
            state.wallpaper.loading.local.images));
    this.watch<WallpaperCollectionsElement['collections_']>(
        'collections_', state => state.wallpaper.backdrop.collections);
    this.watch<WallpaperCollectionsElement['images_']>(
        'images_', state => state.wallpaper.backdrop.images);
    this.watch<WallpaperCollectionsElement['imagesLoading_']>(
        'imagesLoading_', state => state.wallpaper.loading.images);
    this.watch<WallpaperCollectionsElement['googlePhotosEnabled_']>(
        'googlePhotosEnabled_', state => state.wallpaper.googlePhotos.enabled);
    this.watch<WallpaperCollectionsElement['localImages_']>(
        'localImages_', state => state.wallpaper.local.images);
    // Treat as loading if either loading local images list or loading the
    // default image thumbnail. This prevents rapid churning of the UI on first
    // load.
    this.watch<WallpaperCollectionsElement['localImagesLoading_']>(
        'localImagesLoading_',
        state => state.wallpaper.loading.local.images ||
            state.wallpaper.loading.local.data[kDefaultImageSymbol]);
    this.watch<WallpaperCollectionsElement['localImageData_']>(
        'localImageData_', state => state.wallpaper.local.data);
    this.updateFromStore();
    initializeBackdropData(getWallpaperProvider(), this.getStore());
    getDefaultImageThumbnail(getWallpaperProvider(), this.getStore());
    fetchLocalData(getWallpaperProvider(), this.getStore());
    window.addEventListener('focus', () => {
      fetchLocalData(getWallpaperProvider(), this.getStore());
    });
    if (isGooglePhotosIntegrationEnabled()) {
      fetchGooglePhotosEnabled(getWallpaperProvider(), this.getStore());
    }
    this.setSeaPenPromptingTile_();
    this.setSeaPenTemplatesTile_();
  }

  /**
   * Tiles are laid out
   * `[time_of_day?, local, google_photos?, ...regular backdrop tiles...]`.
   * Get the index of the first regular backdrop tile.
   * @returns the index of the first regular backdrop tile.
   */
  private getFirstRegularBackdropTileIndex(): number {
    const firstBackdropIndex = this.tiles_.findIndex(
        tile => tile.id !== kLocalCollectionId &&
            tile.id !== kGooglePhotosCollectionId && !isTimeOfDay(tile) &&
            tile.id !== kSeaPenId);
    assert(
        firstBackdropIndex > 0,
        'first backdrop index must always be greater than 0');
    return firstBackdropIndex;
  }

  /**
   * Notify that this element visibility has changed.
   */
  private async onHiddenChanged_(hidden: boolean) {
    if (!hidden) {
      document.title = this.i18n('wallpaperLabel');
    }
    afterNextRender(this, () => {
      this.$.grid.fire('iron-resize');
      (this.shadowRoot!.getElementById('promoted') as IronListElement | null)
          ?.fire('iron-resize');
    });
  }

  /**
   * Called when the list of wallpaper collections changes. Collections are not
   * actually displayed until they have completed loading, which is handled by
   * `onCollectionLoaded_`.
   */
  private onCollectionsChanged_(collections: WallpaperCollection[]|null) {
    if (!isNonEmptyArray(collections)) {
      this.splitCollections_ = null;
      return;
    }

    const timeOfDay = collections.find(isTimeOfDay) ?? null;
    if (!timeOfDay && isTimeOfDayWallpaperEnabled()) {
      console.error('missing time of day wallpaper from collections');
      this.tiles_ = this.tiles_.filter(tile => !isTimeOfDay(tile));
      this.promotedTiles_ =
          this.promotedTiles_.filter(tile => !isTimeOfDay(tile));
    }

    // Delay assigning `this.splitCollections_` until the correct number of
    // tiles are assigned.
    const splitCollections = {
      regular: collections.filter(collection => !isTimeOfDay(collection)),
      timeOfDay,
    };

    // This is the index of the first tile after the "special" tiles like time
    // of day, local images, and google photos.
    const firstBackdropIndex = this.getFirstRegularBackdropTileIndex();
    const desiredNumTiles =
        splitCollections.regular.length + firstBackdropIndex;

    // Adjust the number of loading tiles to match the collections that just
    // came in.  There may be more (or fewer) loading tiles than necessary to
    // display all collections. Match the number of tiles to the correct length.
    if (this.tiles_.length < desiredNumTiles) {
      this.push(
          'tiles_',
          ...Array.from(
              {length: desiredNumTiles - this.tiles_.length},
              (_, i): LoadingTile => {
                return {
                  type: TileType.LOADING,
                  id: getTemporaryBackdropCollectionId(
                      i - firstBackdropIndex + this.tiles_.length),
                };
              }));
    }
    if (this.tiles_.length > desiredNumTiles) {
      this.splice('tiles_', desiredNumTiles);
    }

    // Assign `this.splitCollections_` now that
    // `tiles_.length === desiredNumTiles`.
    this.splitCollections_ = splitCollections;
  }

  /**
   * Calculate count of image units in each collection when a new collection is
   * fetched. D/L variants of the same image represent a count of 1.
   */
  private computeImageCounts_(
      images: Record<string, WallpaperImage[]|null>,
      imagesLoading: Record<string, boolean>): Record<string, number|null> {
    if (!images || !imagesLoading) {
      return {};
    }
    return Object.entries(images)
        .filter(([collectionId]) => {
          return imagesLoading[collectionId] === false;
        })
        .map(([key, value]) => {
          // Collection has completed loading. If no images were
          // retrieved, set count value to null to indicate
          // failure.
          if (Array.isArray(value)) {
            const unitIds = new Set();
            value.forEach(image => {
              unitIds.add(image.unitId);
            });
            return [key, unitIds.size] as [string, number];
          } else {
            return [key, null] as [string, null];
          }
        })
        .reduce((result, [key, value]) => {
          result[key] = value;
          return result;
        }, {} as Record<string, number|null>);
  }

  private getLoadingPlaceholderAnimationDelay_(index: number): string {
    return getLoadingPlaceholderAnimationDelay(index);
  }

  /**
   * Called each time a new collection finishes loading. |imageCounts| contains
   * a mapping of collection id to the number of images in that collection.
   * A value of null indicates that the given collection id has failed to load.
   */
  private onCollectionLoaded_(
      splitCollections: SplitCollections|null,
      imageCounts: Record<string, number|null>) {
    if (!splitCollections || !isNonEmptyArray(splitCollections.regular) ||
        !imageCounts) {
      return;
    }

    const firstBackdropIndex = this.getFirstRegularBackdropTileIndex();

    splitCollections.regular.forEach((collection, i) => {
      assert(
          isNonEmptyArray(collection.previews),
          `preview images required for collection ${collection.id}`);

      const index = i + firstBackdropIndex;
      const tile = this.tiles_[index];

      if (imageCounts[collection.id] === undefined) {
        // Collection is still loading, skip.
        return;
      }
      const newTile = getOnlineTile(collection, imageCounts[collection.id]);
      if (tile.type !== newTile.type || tile.id !== newTile.id ||
          tile.count !== newTile.count) {
        this.set(`tiles_.${index}`, newTile);
      }
    });

    if (splitCollections.timeOfDay &&
        imageCounts[splitCollections.timeOfDay.id] !== undefined) {
      const timeOfDayContainer =
          isSeaPenEnabled() ? this.promotedTiles_ : this.tiles_;
      const tileIndex = timeOfDayContainer.findIndex(isTimeOfDay);
      if (tileIndex < 0) {
        console.warn('received time of day collection when not supported');
        return;
      }
      const tile = timeOfDayContainer[tileIndex];
      const newTile = getOnlineTile(
          splitCollections.timeOfDay,
          imageCounts[splitCollections.timeOfDay.id]);
      if (tile.type !== newTile.type || tile.count !== newTile.count) {
        if (isSeaPenEnabled()) {
          this.set(`promotedTiles_.${tileIndex}`, newTile);
        } else {
          this.set(`tiles_.${tileIndex}`, newTile);
        }
      }
    }
  }

  /** Invoked on changes to |googlePhotosEnabled_|. */
  private onGooglePhotosEnabledChanged_(googlePhotosEnabled:
                                            GooglePhotosEnablementState|
                                        undefined) {
    if (googlePhotosEnabled !== undefined) {
      assert(
          isGooglePhotosIntegrationEnabled(),
          'google photos integration must be enabled');
      const tile = getGooglePhotosTile(googlePhotosEnabled);
      const index =
          this.tiles_.findIndex(tile => tile.id === kGooglePhotosCollectionId);
      assert(index >= 0, 'could not find google photos tile');
      this.set(`tiles_.${index}`, tile);
    }
  }

  private setSeaPenPromptingTile_() {
    if (!isSeaPenTextInputEnabled()) {
      return;
    }
    const tile = getSeaPenPromptingTile();
    const index =
        this.promotedTiles_.findIndex(tile => tile.id === kSeaPenPromptingId);
    assert(index >= 0, `${kSeaPenPromptingId} not found`);
    this.set(`promotedTiles_.${index}`, tile);
  }

  private setSeaPenTemplatesTile_() {
    if (!isSeaPenEnabled()) {
      return;
    }
    const tile = getSeaPenTemplatesTile();
    const index = this.promotedTiles_.findIndex(tile => tile.id === kSeaPenId);
    assert(index >= 0, `${kSeaPenId} not found`);
    this.set(`promotedTiles_.${index}`, tile);
  }

  /**
   * Called with updated local image list or local image thumbnail data when
   * either of those properties changes.
   */
  private onLocalImagesChanged_(
      localImages: Array<FilePath|DefaultImageSymbol>|null,
      localImagesLoading: boolean,
      localImageData: Record<FilePath['path']|DefaultImageSymbol, Url>) {
    const newLocalTile =
        getLocalTile(localImages, localImagesLoading, localImageData);
    const index = this.tiles_.findIndex(tile => tile.id === kLocalCollectionId);
    assert(index >= 0, 'could not find local tile');
    const currentLocalTile = this.get(`tiles_.${index}`);
    if (this.isLoadingTile_(currentLocalTile) ||
        !isNonEmptyArray(localImages) || this.isLocalTile_(newLocalTile)) {
      // Displays loading tile only when no preview images have been fetched. If
      // the local tile has already had some preview images, do not display
      // loading again to avoid flickering.
      this.set(`tiles_.${index}`, newLocalTile);
    }
  }

  /** Navigate to the correct route based on user selection. */
  private onCollectionSelected_(e: OnCollectionSelectedEvent) {
    const tile = e.model.item;
    assert(!!tile, 'tile must be set to select');
    if (!this.isSelectableTile_(tile)) {
      // Ignore all events from disabled/loading tiles.
      return;
    }
    if (!(e instanceof WallpaperGridItemSelectedEvent) &&
        !isSelectionEvent(e)) {
      // While refactoring is in progress, may receive either a
      // `WallpaperGridItemSelectedEvent` or a mouse/keyboard selection event.
      // If not one of those two event types, ignore it and let it propagate.
      return;
    }
    switch (tile.id) {
      case kGooglePhotosCollectionId:
        PersonalizationRouterElement.instance().goToRoute(
            Paths.GOOGLE_PHOTOS_COLLECTION);
        return;
      case kLocalCollectionId:
        PersonalizationRouterElement.instance().goToRoute(
            Paths.LOCAL_COLLECTION);
        return;
      case kSeaPenId:
        PersonalizationRouterElement.instance().goToRoute(
            Paths.SEA_PEN_COLLECTION);
        return;
      case kSeaPenPromptingId:
        // cleans up the Sea Pen states such as thumbnail response status code,
        // thumbnail loading status and Sea Pen query when
        // switching template; otherwise, states from the last query search will
        // remain in sea-pen-images element.
        cleanUpSeaPenQueryStates(getSeaPenStore());
        PersonalizationRouterElement.instance().goToRoute(
            Paths.SEA_PEN_FREEFORM);
        return;
      default:
        assert(
            isNonEmptyArray(this.collections_), 'collections array required');
        const collection =
            this.collections_.find(collection => collection.id === tile.id);
        assert(collection, 'collection with matching id required');
        PersonalizationRouterElement.instance().selectCollection(collection);
        return;
    }
  }

  private isLoadingTile_(item: Tile|null): item is LoadingTile {
    return !!item && item.type === TileType.LOADING;
  }

  private isLocalTile_(item: Tile|null): item is LocalTile {
    return !!item && item.type === TileType.IMAGE_LOCAL;
  }

  private isSeaPenPromptingTile_(item: Tile|null): item is SeaPenPromptingTile {
    return !!item && item.type === TileType.SEA_PEN_PROMPTING;
  }

  private isSeaPenTemplatesTile_(item: Tile|null): item is SeaPenTemplatesTile {
    return !!item && item.type === TileType.SEA_PEN_TEMPLATES;
  }

  private isOnlineTile_(item: Tile|null): item is OnlineTile {
    return !!item && item.type === TileType.IMAGE_ONLINE;
  }

  private isGooglePhotosTile_(item: Tile|null): item is GooglePhotosTile {
    return !!item && item.type === TileType.IMAGE_GOOGLE_PHOTOS;
  }

  private isSelectableTile_(item: Tile|null): item is GooglePhotosTile|LocalTile
      |OnlineTile {
    return !!item && !this.isLoadingTile_(item) && !item.disabled;
  }

  private isLocalNoImagesTile_(item: Tile): boolean {
    return !!item && this.isLocalTile_(item) && item.count === getCountText(0);
  }

  private isTimeOfDayCollection_(item: Tile|null): boolean {
    return this.isOnlineTile_(item) && isTimeOfDay(item);
  }

  private getOuterContainerClass_(): string {
    return isSeaPenTextInputEnabled() ? 'outer-container-with-prompting' :
                                        'outer-container';
  }

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

  private getSeaPenTemplatesTileLabel_(): string {
    return isSeaPenTextInputEnabled() ?
        this.i18n('seaPenFreeformWallpaperTemplatesLabel') :
        this.i18n('seaPenLabel');
  }

  private getOnlineTileSecondaryText_(item: Tile): string {
    assert(this.isOnlineTile_(item), 'item must be online tile');
    if (this.isTimeOfDayCollection_(item)) {
      return loadTimeData.getString('timeOfDayWallpaperCollectionSublabel');
    }
    return item.count;
  }
}

customElements.define(
    WallpaperCollectionsElement.is, WallpaperCollectionsElement);