chromium/ash/webui/personalization_app/resources/js/wallpaper/google_photos_photos_by_album_id_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 Google Photos photos
 * for the currently selected album id.
 */

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-list/iron-list.js';
import 'chrome://resources/polymer/v3_0/iron-scroll-threshold/iron-scroll-threshold.js';

import {WallpaperGridItemSelectedEvent} from 'chrome://resources/ash/common/personalization/wallpaper_grid_item_element.js';
import {assert} from 'chrome://resources/js/assert.js';
import {IronListElement} from 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import {IronScrollThresholdElement} from 'chrome://resources/polymer/v3_0/iron-scroll-threshold/iron-scroll-threshold.js';
import {afterNextRender} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {CurrentWallpaper, GooglePhotosAlbum, GooglePhotosPhoto, WallpaperProviderInterface, WallpaperType} from '../../personalization_app.mojom-webui.js';
import {isGooglePhotosSharedAlbumsEnabled} from '../load_time_booleans.js';
import {dismissErrorAction, setErrorAction} from '../personalization_actions.js';
import {PersonalizationStateError} from '../personalization_state.js';
import {WithPersonalizationStore} from '../personalization_store.js';

import {DisplayableImage} from './constants.js';
import {recordWallpaperGooglePhotosSourceUMA, WallpaperGooglePhotosSource} from './google_photos_metrics_logger.js';
import {getTemplate} from './google_photos_photos_by_album_id_element.html.js';
import {findAlbumById, getLoadingPlaceholders, isGooglePhotosPhoto, isImageAMatchForKey, isImageEqualToSelected} from './utils.js';
import {fetchGooglePhotosAlbum, selectWallpaper} from './wallpaper_controller.js';
import {getWallpaperProvider} from './wallpaper_interface_provider.js';

const ERROR_ID = 'GooglePhotosByAlbumId';
const PLACEHOLDER_ID = 'placeholder';

/** Returns placeholders to show while Google Photos photos are loading. */
function getPlaceholders(): GooglePhotosPhoto[] {
  return getLoadingPlaceholders(() => {
    return {
      id: PLACEHOLDER_ID,
      name: '',
      date: {data: []},
      url: {url: ''},
      dedupKey: null,
      location: null,
    };
  });
}

export interface GooglePhotosPhotosByAlbumIdElement {
  $: {grid: IronListElement, gridScrollThreshold: IronScrollThresholdElement};
}

export class GooglePhotosPhotosByAlbumIdElement extends
    WithPersonalizationStore {
  static get is() {
    return 'google-photos-photos-by-album-id';
  }

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

  static get properties() {
    return {
      albumId: String,

      hidden: {
        type: Boolean,
        value: true,
        reflectToAttribute: true,
        observer: 'onHiddenChanged_',
      },

      album_: {
        type: Array,
        value: getPlaceholders,
      },

      albums_: Array,
      albumsShared_: Array,
      currentSelected_: Object,
      pendingSelected_: Object,
      photosByAlbumId_: Object,
      photosByAlbumIdLoading_: Object,
      photosByAlbumIdResumeTokens_: Object,

      isSharedAlbumsEnabled_: {
        type: Boolean,
        value() {
          return isGooglePhotosSharedAlbumsEnabled();
        },
      },

      error_: {
        type: Object,
        value: null,
      },
    };
  }

  static get observers() {
    return [
      'onAlbumIdOrAlbumsOrPhotosByAlbumIdChanged_(albumId, albums_, albumsShared_, photosByAlbumId_)',
      'onAlbumIdOrPhotosByAlbumIdResumeTokensChanged_(albumId, photosByAlbumIdResumeTokens_)',
    ];
  }

  /** The currently selected album id. */
  albumId: string|undefined;

  /** Whether or not this element is currently hidden. */
  override hidden: boolean;

  /** The list of photos for the currently selected album id. */
  private album_: GooglePhotosPhoto[];

  /** The list of Google Photos albums. */
  private albums_: GooglePhotosAlbum[]|null|undefined;

  /** The list of shared Google Photos albums. */
  private albumsShared_: GooglePhotosAlbum[]|null|undefined;

  /** The currently selected wallpaper. */
  private currentSelected_: CurrentWallpaper|null;

  /** The pending selected wallpaper. */
  private pendingSelected_: DisplayableImage|null;

  /** The list of photos by album id. */
  private photosByAlbumId_: Record<string, GooglePhotosPhoto[]|null|undefined>|
      undefined;

  /** Whether the list of photos by album id is currently loading. */
  private photosByAlbumIdLoading_: Record<string, boolean>|undefined;

  /** The resume tokens needed to fetch the next page of photos by album id. */
  private photosByAlbumIdResumeTokens_: Record<string, string|null>|undefined;

  /** Whether feature flag |kGooglePhotosSharedAlbums| is enabled. */
  private isSharedAlbumsEnabled_: boolean;

  /** The current personalization error state. */
  private error_: PersonalizationStateError|null;

  /** The singleton wallpaper provider interface. */
  private wallpaperProvider_: WallpaperProviderInterface =
      getWallpaperProvider();

  override connectedCallback() {
    super.connectedCallback();

    this.watch<GooglePhotosPhotosByAlbumIdElement['albums_']>(
        'albums_', state => state.wallpaper.googlePhotos.albums);
    this.watch<GooglePhotosPhotosByAlbumIdElement['albumsShared_']>(
        'albumsShared_', state => state.wallpaper.googlePhotos.albumsShared);
    this.watch<GooglePhotosPhotosByAlbumIdElement['currentSelected_']>(
        'currentSelected_', state => state.wallpaper.currentSelected);
    this.watch<GooglePhotosPhotosByAlbumIdElement['pendingSelected_']>(
        'pendingSelected_', state => state.wallpaper.pendingSelected);
    this.watch<GooglePhotosPhotosByAlbumIdElement['photosByAlbumId_']>(
        'photosByAlbumId_',
        state => state.wallpaper.googlePhotos.photosByAlbumId);
    this.watch<GooglePhotosPhotosByAlbumIdElement['photosByAlbumIdLoading_']>(
        'photosByAlbumIdLoading_',
        state => state.wallpaper.loading.googlePhotos.photosByAlbumId);
    this.watch<
        GooglePhotosPhotosByAlbumIdElement['photosByAlbumIdResumeTokens_']>(
        'photosByAlbumIdResumeTokens_',
        state => state.wallpaper.googlePhotos.resumeTokens.photosByAlbumId);
    this.watch<GooglePhotosPhotosByAlbumIdElement['error_']>(
        'error_', state => state.error);

    this.updateFromStore();
  }

  /** Invoked on grid scroll threshold reached. */
  private onGridScrollThresholdReached_() {
    // Ignore this event if fired during initialization.
    if (!this.$.gridScrollThreshold.scrollHeight || !this.albumId) {
      this.$.gridScrollThreshold.clearTriggers();
      return;
    }

    // Ignore this event if photos are already being loaded.
    if (this.photosByAlbumIdLoading_ &&
        this.photosByAlbumIdLoading_[this.albumId] === true) {
      return;
    }


    // Ignore this event if there is no resume token (indicating there are no
    // additional photos to load).
    if (!this.photosByAlbumIdResumeTokens_ ||
        !this.photosByAlbumIdResumeTokens_[this.albumId]) {
      return;
    }

    // Fetch the next page of photos.
    fetchGooglePhotosAlbum(
        this.wallpaperProvider_, this.getStore(), this.albumId);
  }

  /** Invoked on changes to this element's |hidden| state. */
  private onHiddenChanged_(hidden:
                               GooglePhotosPhotosByAlbumIdElement['hidden']) {
    if (hidden && this.error_ && this.error_.id === ERROR_ID) {
      // If |hidden|, the error associated with this element will have lost
      // user-facing context so it should be dismissed.
      this.dispatch(dismissErrorAction(ERROR_ID, /*fromUser=*/ false));
      return;
    }

    // When iron-list items change while their parent element is hidden, the
    // iron-list will render incorrectly. Force relayout by invalidating the
    // iron-list when this element becomes visible.
    afterNextRender(this, () => this.$.grid.fire('iron-resize'));

    // When the user reselects an album that previously failed to load we should
    // automatically retry loading the selected album. Placeholders should be
    // shown while loading is in progress.
    if (this.albumId && this.photosByAlbumId_ && this.photosByAlbumIdLoading_ &&
        this.photosByAlbumId_[this.albumId] === null &&
        !this.photosByAlbumIdLoading_[this.albumId]) {
      fetchGooglePhotosAlbum(
          this.wallpaperProvider_, this.getStore(), this.albumId);
      this.album_ = getPlaceholders();
    }
  }

  /** Invoked on changes to |albumId|, |albums_|, or |photosByAlbumId_|. */
  private onAlbumIdOrAlbumsOrPhotosByAlbumIdChanged_(
      albumId: GooglePhotosPhotosByAlbumIdElement['albumId'],
      albums: GooglePhotosPhotosByAlbumIdElement['albums_'],
      albumsShared: GooglePhotosPhotosByAlbumIdElement['albumsShared_'],
      photosByAlbumId: GooglePhotosPhotosByAlbumIdElement['photosByAlbumId_']) {
    // If no album is currently selected there is nothing to display.
    if (!albumId) {
      this.album_ = [];
      return;
    }

    // If the album associated with |albumId| or |photosByAlbumId| have not yet
    // been set, there is nothing to display except placeholders. This occurs
    // if the user refreshes the wallpaper app while its navigated to a Google
    // Photos album.
    const matchingAlbum =
        findAlbumById(albumId, albums) ?? findAlbumById(albumId, albumsShared);
    if (!matchingAlbum || !photosByAlbumId) {
      this.album_ = getPlaceholders();
      return;
    }

    // If the currently selected album has not already been fetched, do so
    // though there is still nothing to display except placeholders.
    if (!photosByAlbumId.hasOwnProperty(albumId)) {
      fetchGooglePhotosAlbum(this.wallpaperProvider_, this.getStore(), albumId);
      this.album_ = getPlaceholders();
      return;
    }

    // If the currently selected album fails to load, display an error to the
    // user that allows them to make another attempt.
    if (photosByAlbumId[albumId] === null) {
      if (!this.hidden) {
        this.dispatch(setErrorAction({
          id: ERROR_ID,
          message: this.i18n('googlePhotosError'),
          dismiss: {
            message: this.i18n('googlePhotosTryAgain'),
            callback: (fromUser: boolean) => {
              if (fromUser) {
                // Post the reattempt instead of performing it immediately to
                // avoid updating the personalization store from the same
                // sequence that generated this event.
                setTimeout(
                    () => fetchGooglePhotosAlbum(
                        this.wallpaperProvider_, this.getStore(), albumId));
              }
            },
          },
        }));
      }
      return;
    }

    // NOTE: |album_| is updated in place to avoid resetting the scroll
    // position of the grid which would otherwise occur during reassignment.
    this.updateList(
        /*propertyPath=*/ 'album_',
        /*identityGetter=*/ (photo: GooglePhotosPhoto) => photo.id,
        /*newList=*/ photosByAlbumId[albumId] || [],
        /*identityBasedUpdate=*/ true);
  }

  /** Invoked on changes to |albumId| or |photosByAlbumIdResumeTokens_|. */
  private onAlbumIdOrPhotosByAlbumIdResumeTokensChanged_(
      albumId: GooglePhotosPhotosByAlbumIdElement['albumId'],
      photosByAlbumIdResumeTokens:
          GooglePhotosPhotosByAlbumIdElement['photosByAlbumIdResumeTokens_']) {
    if (albumId && photosByAlbumIdResumeTokens &&
        photosByAlbumIdResumeTokens[albumId]) {
      this.$.gridScrollThreshold.clearTriggers();
    }
  }

  /** Invoked on selection of a photo. `e.model.photo` is added by iron-list. */
  private onPhotoSelected_(e: WallpaperGridItemSelectedEvent&
                           {model: {photo: GooglePhotosPhoto}}) {
    assert(e.model.photo, 'google photos album photo selected event has photo');
    if (!this.isPhotoPlaceholder_(e.model.photo)) {
      selectWallpaper(e.model.photo, this.wallpaperProvider_, this.getStore());
      // Depends on whether shared albums feature is enabled, records Google
      // Photos source metric for all albums, owned albums or shared albums
      // accordingly.
      if (!this.isSharedAlbumsEnabled_) {
        recordWallpaperGooglePhotosSourceUMA(
            WallpaperGooglePhotosSource.ALBUMS);
      } else {
        const isAlbumShared =
            this.isAlbumShared_(this.albumId, this.albums_, this.albumsShared_);
        if (isAlbumShared !== null) {
          recordWallpaperGooglePhotosSourceUMA(
              isAlbumShared ? WallpaperGooglePhotosSource.SHARED_ALBUMS :
                              WallpaperGooglePhotosSource.OWNED_ALBUMS);
        }
      }
    }
  }

  /** Checks whether an album with albumId id is a shared or owned album. */
  private isAlbumShared_(
      albumId: GooglePhotosPhotosByAlbumIdElement['albumId'],
      albums: GooglePhotosPhotosByAlbumIdElement['albums_'],
      albumsShared: GooglePhotosPhotosByAlbumIdElement['albumsShared_']):
      boolean|null {
    if (findAlbumById(albumId, albums)) {
      return false;
    } else if (findAlbumById(albumId, albumsShared)) {
      return true;
    }
    console.warn('No matching album id found. ', albumId);
    return null;
  }


  /** Returns the aria label for the specified |photo|. */
  private getPhotoAriaLabel_(photo: GooglePhotosPhoto|null): string|undefined {
    if (photo) {
      return photo.id === PLACEHOLDER_ID ? this.i18n('ariaLabelLoading') :
                                           photo.name;
    }
    return undefined;
  }

  /** Returns the aria posinset index for the photo at index |i|. */
  private getPhotoAriaIndex_(i: number): number {
    return i + 1;
  }

  /** Returns whether the specified |photo| is a placeholder. */
  private isPhotoPlaceholder_(photo: GooglePhotosPhoto|null): boolean {
    return !!photo && photo.id === PLACEHOLDER_ID;
  }

  /** Returns whether the specified |photo| is currently selected. */
  private isPhotoSelected_(
      photo: GooglePhotosPhoto|null,
      currentSelected: GooglePhotosPhotosByAlbumIdElement['currentSelected_'],
      pendingSelected: GooglePhotosPhotosByAlbumIdElement['pendingSelected_']):
      boolean {
    if (!photo || (!currentSelected && !pendingSelected)) {
      return false;
    }
    // NOTE: Old clients may not support |dedupKey| when setting Google Photos
    // wallpaper, so use |id| in such cases for backwards compatibility.
    if (isGooglePhotosPhoto(pendingSelected) &&
        ((pendingSelected!.dedupKey &&
          isImageAMatchForKey(photo, pendingSelected!.dedupKey)) ||
         isImageAMatchForKey(photo, pendingSelected!.id))) {
      return true;
    }
    if (!pendingSelected && !!currentSelected &&
        (currentSelected.type === WallpaperType.kOnceGooglePhotos ||
         currentSelected.type === WallpaperType.kDailyGooglePhotos) &&
        isImageEqualToSelected(photo, currentSelected)) {
      return true;
    }
    return false;
  }
}

customElements.define(
    GooglePhotosPhotosByAlbumIdElement.is, GooglePhotosPhotosByAlbumIdElement);