chromium/ash/webui/personalization_app/resources/js/wallpaper/google_photos_photos_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 photos.
 */

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 {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 {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, GooglePhotosPhoto, WallpaperProviderInterface, WallpaperType} from '../../personalization_app.mojom-webui.js';
import {dismissErrorAction, setErrorAction} from '../personalization_actions.js';
import {PersonalizationStateError} from '../personalization_state.js';
import {WithPersonalizationStore} from '../personalization_store.js';
import {getNumberOfGridItemsPerRow} from '../utils.js';

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

const ERROR_ID = 'GooglePhotosPhotos';
const PLACEHOLDER_ID = 'placeholder';

/** Returns placeholders to show while Google Photos photos are loading. */
function getPlaceholders(): GooglePhotosPhotosRow[] {
  const placeholdersPerRow = getNumberOfGridItemsPerRow();
  const placeholders: GooglePhotosPhotosRow[] = [];
  getLoadingPlaceholders(() => {
    return {
      id: PLACEHOLDER_ID,
      name: '',
      date: {data: []},
      url: {url: ''},
      dedupKey: null,
      location: null,
    };
  }).forEach((placeholder, i) => {
    let row = placeholders[placeholders.length - 1];
    if (!row || row.length === placeholdersPerRow) {
      row = [];
      placeholders.push(row);
    }
    row.push({...placeholder, index: i});
  });
  return placeholders;
}

/**
 * Normalizes the given |key| for RTL.
 */
export function normalizeKeyForRTL(key: string, isRTL: boolean): string {
  if (isRTL) {
    if (key === 'ArrowLeft') {
      return 'ArrowRight';
    }
    if (key === 'ArrowRight') {
      return 'ArrowLeft';
    }
  }
  return key;
}

/** A single |GooglePhotosPhoto| coupled with its numerical index. */
export type GooglePhotosPhotoWithIndex = GooglePhotosPhoto&{index: number};

/** A list of |GooglePhotosPhotoWithIndex|'s to be rendered in a row. */
export type GooglePhotosPhotosRow = GooglePhotosPhotoWithIndex[];

/** A titled list of |GooglePhotosPhotosRow|'s to be rendered in a section. */
export interface GooglePhotosPhotosSection {
  date: string;
  locations: Set<string>;
  rows: GooglePhotosPhotosRow[];
}

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

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

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

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

      currentSelected_: Object,

      focusedPhotoIndex_: {
        type: Number,
        value: -1,
        observer: 'onFocusedPhotoIndexChanged_',
      },

      pendingSelected_: Object,
      photos_: Array,

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

      photosBySection_: {
        type: Array,
        computed: 'computePhotosBySection_(photos_, photosPerRow_)',
        observer: 'onPhotosBySectionChanged_',
      },

      photosLoading_: Boolean,

      photosPerRow_: {
        type: Number,
        value: function() {
          return getNumberOfGridItemsPerRow();
        },
        observer: 'onPhotosPerRowChanged_',
      },

      photosResumeToken_: {
        type: String,
        observer: 'onPhotosResumeTokenChanged_',
      },

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

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

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

  /** The index of the currently focused photo. */
  private focusedPhotoIndex_: number;

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

  /** The list of photos. */
  private photos_: GooglePhotosPhoto[]|null|undefined;

  /**
   * The list of |photos_| split into the appropriate number of |photosPerRow_|
   * so as to be rendered in a grid.
   */
  private photosByRow_: GooglePhotosPhotosRow[];

  /**
   * The list of |photos_| split into the appropriate number of |photosPerRow_|
   * and grouped into sections so as to be rendered in a grid.
   */
  private photosBySection_: GooglePhotosPhotosSection[]|null|undefined;

  /** Whether the list of photos is currently loading. */
  private photosLoading_: boolean;

  /** The number of photos to render per row in a grid. */
  private photosPerRow_: number;

  /** The resume token needed to fetch the next page of photos. */
  private photosResumeToken_: string|null;

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

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

  override connectedCallback() {
    super.connectedCallback();

    this.addEventListener('iron-resize', this.onResized_.bind(this));

    this.watch<GooglePhotosPhotosElement['currentSelected_']>(
        'currentSelected_', state => state.wallpaper.currentSelected);
    this.watch<GooglePhotosPhotosElement['pendingSelected_']>(
        'pendingSelected_', state => state.wallpaper.pendingSelected);
    this.watch<GooglePhotosPhotosElement['photos_']>(
        'photos_', state => state.wallpaper.googlePhotos.photos);
    this.watch<GooglePhotosPhotosElement['photosLoading_']>(
        'photosLoading_', state => state.wallpaper.loading.googlePhotos.photos);
    this.watch<GooglePhotosPhotosElement['photosResumeToken_']>(
        'photosResumeToken_',
        state => state.wallpaper.googlePhotos.resumeTokens.photos);
    this.watch<GooglePhotosPhotosElement['error_']>(
        'error_', state => state.error);
    this.updateFromStore();
  }

  /** Invoked on changes to |focusedPhotoIndex_|. */
  private onFocusedPhotoIndexChanged_(
      focusedPhotoIndex: GooglePhotosPhotosElement['focusedPhotoIndex_']) {
    // Attempt to focus the |element| at the focused index. Note that the
    // |element| may not be rendered as it could exist outside of the viewport.
    const selector = `.photo[photoindex="${focusedPhotoIndex}"]`;
    const element = this.$.grid.querySelector<HTMLElement>(selector);
    if (element) {
      element.focus();
      return;
    }

    // If the |element| was not rendered, it exists outside of the viewport. To
    // force it to render, focus the grid row which contains the |element| at
    // the focused index. Note that this will automatically trigger another call
    // to |onFocusedPhotoIndexChanged()|.
    this.photosByRow_.some((row, rowIndex) => {
      if (row.some(photo => photo.index === focusedPhotoIndex)) {
        this.$.grid.focusItem(rowIndex);
        return true;
      }
      return false;
    });
  }

  /** 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 photos are already being loading or if there is no
    // resume token (indicating there are no additional photos to load).
    if (this.photosLoading_ === true || !this.photosResumeToken_) {
      return;
    }

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

  /** Invoked on mouse down of a grid row. */
  private onGridRowMouseDown_(e: Event) {
    // Prevent the odd behavior of auto jumping to the focused photo when the
    // user clicks on any grid row.
    e.preventDefault();
  }

  /** Invoked on focus of a grid row. */
  private onGridRowFocused_() {
    // If |focusedPhotoIndex_| is -1, this is the first time focus has entered
    // the grid. In this case advance focus to the first photo.
    if (this.focusedPhotoIndex_ === -1) {
      this.focusedPhotoIndex_ = 0;
      return;
    }
    // When a grid row is focused, forward the focus event on to the photo at
    // the focused index.
    this.onFocusedPhotoIndexChanged_(this.focusedPhotoIndex_);
  }

  /** Invoked on key down of a grid row. */
  private onGridRowKeyDown_(e: KeyboardEvent&{
    model: {index: number, row: GooglePhotosPhotosRow},
  }) {
    let handled = false;

    switch (normalizeKeyForRTL(e.key, this.i18n('textdirection') === 'rtl')) {
      case 'ArrowDown':
        // To be consistent with default iron-list grid behavior, the down arrow
        // should only advance focus to the next grid row if a photo at the same
        // column index as is currently focused exists.
        if (e.model.index < this.photosByRow_.length - 1) {
          let colIndex = -1;
          e.model.row.some((photo, i) => {
            if (photo.index === this.focusedPhotoIndex_) {
              colIndex = i;
              return true;
            }
            return false;
          });
          assert(colIndex !== -1);
          const nextRow = this.photosByRow_[e.model.index + 1];
          if (colIndex < nextRow.length) {
            this.focusedPhotoIndex_ = nextRow[colIndex].index;
          }
        }
        handled = true;
        break;
      case 'ArrowLeft':
        this.focusedPhotoIndex_ = Math.max(this.focusedPhotoIndex_ - 1, 0);
        handled = true;
        break;
      case 'ArrowRight':
        this.focusedPhotoIndex_ =
            Math.min(this.focusedPhotoIndex_ + 1, this.photos_!.length - 1);
        handled = true;
        break;
      case 'ArrowUp':
        // To be consistent with default iron-list grid behavior, the up arrow
        // should only advance focus to the previous grid row if a photo at the
        // the same column index as is currently focused exists.
        if (e.model.index > 0) {
          let colIndex = -1;
          e.model.row.some((photo, i) => {
            if (photo.index === this.focusedPhotoIndex_) {
              colIndex = i;
              return true;
            }
            return false;
          });
          assert(colIndex !== -1);
          const previousRow = this.photosByRow_[e.model.index - 1];
          if (colIndex < previousRow.length) {
            this.focusedPhotoIndex_ = previousRow[colIndex].index;
          }
        }
        handled = true;
        break;
      case 'Tab':
        // The grid contains a single |focusable| row which becomes a focus trap
        // due to the synthetic redirect of focus events to photos. To escape
        // the trap, make the |focusable| row unfocusable until focus has
        // advanced to the next candidate.
        const focusable = this.$.grid.querySelector('[tabindex="0"]')!;
        focusable.setAttribute('tabindex', '-1');
        afterNextRender(this, () => focusable.setAttribute('tabindex', '0'));
        break;
    }

    if (handled) {
      e.preventDefault();
      e.stopPropagation();
    }
  }

  /** Invoked on changes to this element's |hidden| state. */
  private onHiddenChanged_(hidden: GooglePhotosPhotosElement['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 showing the user a list of photos that previously failed to load,
    // we should automatically retry loading the list. Placeholders should be
    // shown while loading is in progress.
    if (this.photos_ === null && !this.photosLoading_) {
      fetchGooglePhotosPhotos(this.wallpaperProvider_, this.getStore());
      this.photosByRow_ = getPlaceholders();
    }
  }

  /** 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 photos selected event has photo');
    if (!this.isPhotoPlaceholder_(e.model.photo)) {
      selectWallpaper(e.model.photo, this.wallpaperProvider_, this.getStore());
      recordWallpaperGooglePhotosSourceUMA(WallpaperGooglePhotosSource.PHOTOS);
    }
  }

  /** Invoked on changes to |photosBySection_|. */
  private onPhotosBySectionChanged_(
      photosBySection: GooglePhotosPhotosElement['photosBySection_']) {
    if (photosBySection === null) {
      // If the list of photos fails to load and is currently showing, display
      // an error to the user that allows them to make another attempt.
      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(
                    () => fetchGooglePhotosPhotos(
                        this.wallpaperProvider_, this.getStore()));
              }
            },
          },
        }));
      }
      // Whether the list of photos is currently showing or not, placeholders
      // should not be removed on load failure.
      return;
    }

    // NOTE: |photosByRow_| is updated in place to avoid resetting the scroll
    // position of the grid which would otherwise occur during reassignment.
    this.updateList(
        /*propertyPath=*/ 'photosByRow_', /*identityGetter=*/
        (row: GooglePhotosPhotosRow) => row.map(photo => photo.id).join('_'),
        /*newList=*/
        Array.isArray(photosBySection) ?
            photosBySection.flatMap(section => section.rows) :
            [],
        /*identityBasedUpdate=*/ true);
  }

  /** Invoked on changes to |photosPerRow_|. */
  private onPhotosPerRowChanged_(
      photosPerRow: GooglePhotosPhotosElement['photosPerRow_']) {
    // Because this element manually partitions photos by row, placeholders need
    // to be explicitly regenerated when the desired number of photos per row
    // changes.
    if (photosPerRow && isNonEmptyArray(this.photosByRow_) &&
        this.photosByRow_.every(r => r.every(p => p.id === PLACEHOLDER_ID))) {
      this.photosByRow_ = getPlaceholders();
    }
  }

  /** Invoked on changes to |photosResumeToken_|. */
  private onPhotosResumeTokenChanged_(
      photosResumeToken: GooglePhotosPhotosElement['photosResumeToken_']) {
    if (photosResumeToken) {
      this.$.gridScrollThreshold.clearTriggers();
    }
  }

  /** Invoked on resize of this element. */
  private onResized_() {
    this.photosPerRow_ = getNumberOfGridItemsPerRow();
  }

  /** Invoked to compute |photosBySection_|. */
  private computePhotosBySection_(
      photos: GooglePhotosPhotosElement['photos_'],
      photosPerRow: GooglePhotosPhotosElement['photosPerRow_']):
      GooglePhotosPhotosSection[]|null|undefined {
    // If |photos| is undefined, this computation is occurring during
    // initialization. In such cases, defer defining |photosBySection_| until
    // actual data has loaded or failed to load.
    if (photos === undefined) {
      return undefined;
    }

    if (!Array.isArray(photos) || !photosPerRow) {
      return null;
    }

    const sections: GooglePhotosPhotosSection[] = [];

    photos.forEach((photo, i) => {
      const date = mojoString16ToString(photo.date);

      // Find/create the appropriate |section| in which to insert |photo|.
      let section = sections[sections.length - 1];
      if (!section || section.date !== date) {
        section = {date, locations: new Set<string>(), rows: []};
        sections.push(section);
      }

      // Find/create the appropriate |row| in which to insert |photo|.
      let row = section.rows[section.rows.length - 1];
      if (!row || row.length === photosPerRow) {
        row = [];
        section.rows.push(row);
      }

      row.push({...photo, index: i});

      if (photo.location) {
        section.locations.add(photo.location);
      }
    });

    return sections;
  }

  /** Returns the date to display for the specified grid |row|. */
  private getGridRowDate_(
      row: GooglePhotosPhotosRow,
      photosBySection: GooglePhotosPhotosElement['photosBySection_']): string
      |undefined {
    if (!photosBySection) {
      return undefined;
    }
    const gridRowSection =
        photosBySection.find(section => section.rows[0] === row);
    return gridRowSection ? gridRowSection.date : undefined;
  }

  /** Returns the locations to display for the specified grid |row|. */
  private getGridRowLocations_(
      row: GooglePhotosPhotosRow,
      photosBySection: GooglePhotosPhotosElement['photosBySection_']): string
      |undefined {
    if (!photosBySection) {
      return undefined;
    }
    const gridRowSection =
        photosBySection.find(section => section.rows[0] === row);
    return gridRowSection ?
        Array.from(gridRowSection.locations).sort().join(' ยท ') :
        undefined;
  }

  /** Returns the number of photos or placeholders currently being displayed. */
  private getPhotosAriaSetSize_(): number {
    if (this.photos_) {
      return this.photos_.length;
    }

    return getPlaceholders().length * getNumberOfGridItemsPerRow();
  }

  /** 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 title for the specified grid |row| is visible. */
  private isGridRowTitleVisible_(
      row: GooglePhotosPhotosRow,
      photosBySection: GooglePhotosPhotosElement['photosBySection_']): boolean {
    return !!this.getGridRowDate_(row, photosBySection);
  }

  /** 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: GooglePhotosPhotosElement['currentSelected_'],
      pendingSelected: GooglePhotosPhotosElement['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(GooglePhotosPhotosElement.is, GooglePhotosPhotosElement);