chromium/ash/webui/personalization_app/resources/js/wallpaper/google_photos_albums_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 displays Google Photos albums.
 */

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 {isNonEmptyArray} from 'chrome://resources/ash/common/sea_pen/sea_pen_utils.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 {GooglePhotosAlbum, WallpaperProviderInterface} from '../../personalization_app.mojom-webui.js';
import {isGooglePhotosSharedAlbumsEnabled} from '../load_time_booleans.js';
import {dismissErrorAction, setErrorAction} from '../personalization_actions.js';
import {PersonalizationRouterElement} from '../personalization_router_element.js';
import {PersonalizationStateError} from '../personalization_state.js';
import {WithPersonalizationStore} from '../personalization_store.js';
import {getCountText, isRecentHighlightsAlbum} from '../utils.js';

import {getTemplate} from './google_photos_albums_element.html.js';
import {getLoadingPlaceholders} from './utils.js';
import {fetchGooglePhotosAlbums, fetchGooglePhotosSharedAlbums} from './wallpaper_controller.js';
import {getWallpaperProvider} from './wallpaper_interface_provider.js';

const ERROR_ID = 'GooglePhotosAlbums';
const PLACEHOLDER_ID = 'placeholder';

/** Returns placeholders to show while Google Photos albums are loading. */
function getPlaceholders(): GooglePhotosAlbum[] {
  return getLoadingPlaceholders(() => {
    return {
      id: PLACEHOLDER_ID,
      title: '',
      photoCount: 0,
      isShared: false,
      preview: {url: ''},
      timestamp: {internalValue: BigInt(0)},
    };
  });
}

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

export class GooglePhotosAlbumsElement extends WithPersonalizationStore {
  static get is() {
    return 'google-photos-albums';
  }

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

  static get properties() {
    return {
      hidden: {
        type: Boolean,
        value: true,
        reflectToAttribute: true,
        observer: 'onHiddenChanged_',
      },

      albums_: Array,

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

      albumsLoading_: Boolean,

      albumsResumeToken_: String,

      albumsShared_: Array,

      albumsSharedLoading_: Boolean,

      albumsSharedResumeToken_: String,

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

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

  static get observers() {
    return [
      'onAlbumsChanged_(albums_, albumsShared_)',
      'onAlbumsResumeTokenChanged_(albumsResumeToken_, albumsSharedResumeToken_)',
    ];
  }

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

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

  /** Merged |albums_| and |albumsShared_| for display. */
  private albumsForDisplay_: GooglePhotosAlbum[];

  /** Whether the list of owned albums is currently loading. */
  private albumsLoading_: boolean;

  /** The resume token needed to fetch the next page of owned albums. */
  private albumsResumeToken_: string|null;

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

  /** Whether the list of shared albums is currently loading. */
  private albumsSharedLoading_: boolean;

  /** The resume token needed to fetch the next page of shared albums. */
  private albumsSharedResumeToken_: string|null;

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

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

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

  override connectedCallback() {
    super.connectedCallback();

    this.watch<GooglePhotosAlbumsElement['albums_']>(
        'albums_', state => state.wallpaper.googlePhotos.albums);
    this.watch<GooglePhotosAlbumsElement['albumsLoading_']>(
        'albumsLoading_', state => state.wallpaper.loading.googlePhotos.albums);
    this.watch<GooglePhotosAlbumsElement['albumsResumeToken_']>(
        'albumsResumeToken_',
        state => state.wallpaper.googlePhotos.resumeTokens.albums);
    if (this.isSharedAlbumsEnabled_) {
      this.watch<GooglePhotosAlbumsElement['albumsShared_']>(
          'albumsShared_', state => state.wallpaper.googlePhotos.albumsShared);
      this.watch<GooglePhotosAlbumsElement['albumsSharedLoading_']>(
          'albumsSharedLoading_',
          state => state.wallpaper.loading.googlePhotos.albumsShared);
      this.watch<GooglePhotosAlbumsElement['albumsSharedResumeToken_']>(
          'albumsSharedResumeToken_',
          state => state.wallpaper.googlePhotos.resumeTokens.albumsShared);
    }
    this.watch<GooglePhotosAlbumsElement['error_']>(
        'error_', state => state.error);

    this.updateFromStore();
  }

  /** Invoked on selection of an album. */
  private onAlbumSelected_(e: Event&{model: {album: GooglePhotosAlbum}}) {
    assert(e.model.album);
    if (!this.isAlbumPlaceholder_(e.model.album)) {
      PersonalizationRouterElement.instance().selectGooglePhotosAlbum(
          e.model.album);
    }
  }

  private mergeAlbumsByTimestamp_(
      owned: GooglePhotosAlbumsElement['albums_'],
      shared: GooglePhotosAlbumsElement['albumsShared_']) {
    if (!isNonEmptyArray(owned)) {
      owned = [];
    }
    if (!isNonEmptyArray(shared)) {
      shared = [];
    }
    // If the Recent Highlights album exists, it will be the first element in
    // the owned albums, i.e. owned[0].
    let recentHighlights: GooglePhotosAlbum|undefined;
    if (isNonEmptyArray(owned) && isRecentHighlightsAlbum(owned[0])) {
      recentHighlights = owned.shift();
    }
    const albums = (owned).concat(shared).sort(
        (a, b) => Number(b.timestamp.internalValue) -
            Number(a.timestamp.internalValue));
    return recentHighlights ? [recentHighlights].concat(albums) : albums;
  }

  /** Invoked on changes to |albums_| or |albumsShared_|. */
  private onAlbumsChanged_(
      albums: GooglePhotosAlbumsElement['albums_'],
      albumsShared: GooglePhotosAlbumsElement['albumsShared_']) {
    // If the list of albums fails to load, display an error to the user that
    // allows them to make another attempt.
    // When shared albums flag is enabled, also need to make sure |albumsShared|
    // fails to load.
    if (albums === null &&
        !(this.isSharedAlbumsEnabled_ && albumsShared !== 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(() => {
                  fetchGooglePhotosAlbums(
                      this.wallpaperProvider_, this.getStore());
                  if (this.isSharedAlbumsEnabled_) {
                    fetchGooglePhotosSharedAlbums(
                        this.wallpaperProvider_, this.getStore());
                  }
                });
              }
            },
          },
        }));
      }
      return;
    }

    // NOTE: |albumsForDisplay_| is updated in place to avoid resetting the
    // scroll position of the grid which would otherwise occur during
    // reassignment but it will be deeply equal to |newList| after updating.
    this.updateList(
        /*propertyPath=*/ 'albumsForDisplay_',
        /*identityGetter=*/ (album: GooglePhotosAlbum) => album.id,
        /*newList=*/ this.mergeAlbumsByTimestamp_(albums, albumsShared),
        /*identityBasedUpdate=*/ true);
  }

  /**
   * Invoked on changes to |albumsResumeToken_| or |albumsSharedResumeToken_|.
   */
  private onAlbumsResumeTokenChanged_(
      albumsResumeToken: GooglePhotosAlbumsElement['albumsResumeToken_'],
      albumsSharedResumeToken:
          GooglePhotosAlbumsElement['albumsSharedResumeToken_']) {
    if (albumsResumeToken || albumsSharedResumeToken) {
      this.$.gridScrollThreshold.clearTriggers();
    }
  }

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

    // Ignore this event if albums are already being loading or if there is no
    // resume token (indicating there are no additional albums to load).
    const isLoading =
        this.albumsLoading_ === true || this.albumsSharedLoading_ === true;
    const albumResumeTokensPresent =
        this.albumsResumeToken_ || this.albumsSharedResumeToken_;
    if (isLoading || !albumResumeTokensPresent) {
      return;
    }

    // Fetch the next page of owned albums.
    fetchGooglePhotosAlbums(this.wallpaperProvider_, this.getStore());

    // Fetch the next page of shared albums when needed.
    if (this.isSharedAlbumsEnabled_) {
      if (this.albumsSharedLoading_ === true ||
          !this.albumsSharedResumeToken_) {
        return;
      }
      fetchGooglePhotosSharedAlbums(this.wallpaperProvider_, this.getStore());
    }
  }

  /** Invoked on changes to this element's |hidden| state. */
  private onHiddenChanged_(hidden: GooglePhotosAlbumsElement['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'));
  }

  /** Returns the aria label for the specified |album|. */
  private getAlbumAriaLabel_(album: GooglePhotosAlbum|null): string|undefined {
    if (album) {
      return album.id === PLACEHOLDER_ID ?
          this.i18n('ariaLabelLoading') :
          `${album.title} ${this.getSecondaryText_(album)}`;
    }
    return undefined;
  }

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

  /** Returns the secondary text to display for the specified |album|. */
  private getSecondaryText_(album: GooglePhotosAlbum): string {
    return album.isShared ? this.i18n('googlePhotosAlbumShared') :
                            getCountText(album.photoCount);
  }

  /** Returns whether the specified |album| is a placeholder. */
  private isAlbumPlaceholder_(album: GooglePhotosAlbum): boolean {
    return album.id === PLACEHOLDER_ID;
  }
}

customElements.define(GooglePhotosAlbumsElement.is, GooglePhotosAlbumsElement);