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

import {FullscreenPreviewState} from 'chrome://resources/ash/common/personalization/wallpaper_state.js';
import {SeaPenActionName, SeaPenActions} from 'chrome://resources/ash/common/sea_pen/sea_pen_actions.js';
import {seaPenReducer} from 'chrome://resources/ash/common/sea_pen/sea_pen_reducer.js';
import {SeaPenState} from 'chrome://resources/ash/common/sea_pen/sea_pen_state.js';
import {isImageDataUrl, isNonEmptyArray, isNonEmptyFilePath} from 'chrome://resources/ash/common/sea_pen/sea_pen_utils.js';
import {assert} from 'chrome://resources/js/assert.js';
import {FilePath} from 'chrome://resources/mojo/mojo/public/mojom/base/file_path.mojom-webui.js';

import {WallpaperCollection} from '../../personalization_app.mojom-webui.js';
import {Actions} from '../personalization_actions.js';
import {ReducerFunction} from '../personalization_reducers.js';
import {PersonalizationState} from '../personalization_state.js';

import {DefaultImageSymbol, kDefaultImageSymbol} from './constants.js';
import {findAlbumById, isDefaultImage, isImageEqualToSelected} from './utils.js';
import {WallpaperActionName} from './wallpaper_actions.js';
import {DailyRefreshType, WallpaperState} from './wallpaper_state.js';

function backdropReducer(
    state: WallpaperState['backdrop'], action: Actions,
    _: PersonalizationState): WallpaperState['backdrop'] {
  switch (action.name) {
    case WallpaperActionName.SET_COLLECTIONS:
      return {collections: action.collections, images: {}};
    case WallpaperActionName.SET_IMAGES_FOR_COLLECTION:
      if (!state.collections) {
        console.warn('Cannot set images when collections is null');
        return state;
      }
      if (!state.collections.some(({id}) => id === action.collectionId)) {
        console.warn(
            'Cannot store images for unknown collection', action.collectionId);
        return state;
      }
      return {
        ...state,
        images: {...state.images, [action.collectionId]: action.images},
      };
    default:
      return state;
  }
}

function loadingReducer(
    state: WallpaperState['loading'], action: Actions,
    globalState: PersonalizationState): WallpaperState['loading'] {
  switch (action.name) {
    case WallpaperActionName.BEGIN_LOAD_IMAGES_FOR_COLLECTIONS:
      return {
        ...state,
        images: action.collections.reduce(
            (result, {id}) => {
              result[id] = true;
              return result;
            },
            {} as Record<WallpaperCollection['id'], boolean>),
      };
    case WallpaperActionName.BEGIN_LOAD_LOCAL_IMAGE_DATA:
      return {
        ...state,
        local: {...state.local, data: {...state.local.data, [action.id]: true}},
      };
    case WallpaperActionName.BEGIN_LOAD_SELECTED_IMAGE:
      return {...state, selected: {attribution: true, image: true}};
    case WallpaperActionName.BEGIN_SELECT_IMAGE:
      return {...state, setImage: state.setImage + 1};
    case WallpaperActionName.END_SELECT_IMAGE:
      if (state.setImage <= 0) {
        console.error('Impossible state for loading.setImage');
        // Reset to 0.
        return {...state, setImage: 0};
      }
      return {...state, setImage: state.setImage - 1};
    case WallpaperActionName.SET_COLLECTIONS:
      return {...state, collections: false};
    case WallpaperActionName.SET_IMAGES_FOR_COLLECTION:
      return {
        ...state,
        images: {...state.images, [action.collectionId]: false},
      };
    case WallpaperActionName.BEGIN_LOAD_DEFAULT_IMAGE_THUMBNAIL:
      return {
        ...state,
        local: {
          ...state.local,
          data: {
            ...state.local.data,
            [kDefaultImageSymbol]: true,
          },
        },
      };
    case WallpaperActionName.BEGIN_LOAD_LOCAL_IMAGES:
      return {
        ...state,
        local: {
          ...state.local,
          images: true,
        },
      };
    case WallpaperActionName.SET_DEFAULT_IMAGE_THUMBNAIL:
      return {
        ...state,
        local: {
          ...state.local,
          data: {
            ...state.local.data,
            [kDefaultImageSymbol]: false,
          },
        },
      };
    case WallpaperActionName.SET_LOCAL_IMAGES:
      // Only keep loading state for most recent local images and the default
      // image.
      const imagesToKeep: Array<DefaultImageSymbol|FilePath> =
          [kDefaultImageSymbol, ...(action.images || [])];
      return {
        ...state,
        local: {
          data: imagesToKeep.reduce(
              (result, next) => {
                const path = isNonEmptyFilePath(next) ? next.path : next;
                if (state.local.data.hasOwnProperty(path)) {
                  result[path] = state.local.data[path];
                }
                return result;
              },
              {} as Record<FilePath['path']|DefaultImageSymbol, boolean>),
          // Image list is done loading.
          images: false,
        },
      };
    case WallpaperActionName.SET_LOCAL_IMAGE_DATA:
      return {
        ...state,
        local: {
          ...state.local,
          data: {
            ...state.local.data,
            [action.id]: false,
          },
        },
      };
    case WallpaperActionName.SET_SELECTED_IMAGE:
      if (globalState.wallpaper.pendingSelected && action.image &&
          !isImageEqualToSelected(
              globalState.wallpaper.pendingSelected, action.image)) {
        // If the user is in the process of selecting a new image, but the
        // received image does not match what the user last selected, make sure
        // loading.selected stays true.
        return state;
      }
      return {...state, selected: {...state.selected, image: false}};
    case WallpaperActionName.SET_ATTRIBUTION:
      return {...state, selected: {...state.selected, attribution: false}};
    case SeaPenActionName.END_SELECT_SEA_PEN_THUMBNAIL:
    case SeaPenActionName.END_SELECT_RECENT_SEA_PEN_IMAGE:
      // End loading state if selecting a SeaPen image failed. There are no
      // incoming events from wallpaper_observer.ts to reset the loading state
      // from wallpaper side, as the SeaPen image was not saved and applied.
      if (!action.success) {
        return {...state, selected: {image: false, attribution: false}};
      }
      return state;
    case WallpaperActionName.BEGIN_UPDATE_DAILY_REFRESH_IMAGE:
      return {...state, refreshWallpaper: true};
    case WallpaperActionName.SET_UPDATED_DAILY_REFRESH_IMAGE:
      return {...state, refreshWallpaper: false};
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_ALBUM:
      assert(!state.googlePhotos.photosByAlbumId[action.albumId]);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          photosByAlbumId: {
            ...state.googlePhotos.photosByAlbumId,
            [action.albumId]: true,
          },
        },
      };
    case WallpaperActionName.APPEND_GOOGLE_PHOTOS_ALBUM:
      assert(state.googlePhotos.photosByAlbumId[action.albumId] === true);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          photosByAlbumId: {
            ...state.googlePhotos.photosByAlbumId,
            [action.albumId]: false,
          },
        },
      };
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_ALBUMS:
      assert(state.googlePhotos.albums === false);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          albums: true,
        },
      };
    case WallpaperActionName.APPEND_GOOGLE_PHOTOS_ALBUMS:
      assert(state.googlePhotos.albums === true);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          albums: false,
        },
      };
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_SHARED_ALBUMS:
      assert(state.googlePhotos.albumsShared === false);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          albumsShared: true,
        },
      };
    case WallpaperActionName.APPEND_GOOGLE_PHOTOS_SHARED_ALBUMS:
      assert(state.googlePhotos.albumsShared === true);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          albumsShared: false,
        },
      };
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_ENABLED:
      assert(state.googlePhotos.enabled === false);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          enabled: true,
        },
      };
    case WallpaperActionName.SET_GOOGLE_PHOTOS_ENABLED:
      assert(state.googlePhotos.enabled === true);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          enabled: false,
        },
      };
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_PHOTOS:
      assert(state.googlePhotos.photos === false);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          photos: true,
        },
      };
    case WallpaperActionName.APPEND_GOOGLE_PHOTOS_PHOTOS:
      assert(state.googlePhotos.photos === true);
      return {
        ...state,
        googlePhotos: {
          ...state.googlePhotos,
          photos: false,
        },
      };
    default:
      return state;
  }
}

function localReducer(
    state: WallpaperState['local'], action: Actions,
    _: PersonalizationState): WallpaperState['local'] {
  switch (action.name) {
    case WallpaperActionName.SET_DEFAULT_IMAGE_THUMBNAIL:
      if (isImageDataUrl(action.thumbnail)) {
        return {
          images: [
            kDefaultImageSymbol,
            ...(state.images || []).filter(img => isNonEmptyFilePath(img)),
          ],
          data: {
            ...state.data,
            [kDefaultImageSymbol]: action.thumbnail,
          },
        };
      }
      return {
        images: Array.isArray(state.images) ?
            state.images.filter(img => isNonEmptyFilePath(img)) :
            null,
        data: {...state.data, [kDefaultImageSymbol]: {url: ''}},
      };

    case WallpaperActionName.SET_LOCAL_IMAGES: {
      const hasDefaultImageWithData = isNonEmptyArray(state.images) &&
          isDefaultImage(state.images[0]) && !!state.data[kDefaultImageSymbol];

      if (!Array.isArray(action.images)) {
        return {
          // Keep the default image in image list if it is present.
          images: hasDefaultImageWithData ? [kDefaultImageSymbol] : null,
          data: {[kDefaultImageSymbol]: state.data[kDefaultImageSymbol]},
        };
      }
      // If the first image from prior state is the device default image, keep
      // it.
      const newImages: Array<DefaultImageSymbol|FilePath> =
          hasDefaultImageWithData ? [kDefaultImageSymbol, ...action.images] :
                                    action.images;
      return {
        images: newImages,
        // Only keep image thumbnails if the image is still in |images|.
        data: newImages.reduce(
            (result, next) => {
              const key = isNonEmptyFilePath(next) ? next.path : next;
              if (state.data.hasOwnProperty(key)) {
                result[key] = state.data[key];
              }
              return result;
            },
            // Set the default value for |kDefaultImageSymbol| here.
            {[kDefaultImageSymbol]: {url: ''}} as typeof state.data),
      };
    }
    case WallpaperActionName.SET_LOCAL_IMAGE_DATA:
      return {
        ...state,
        data: {
          ...state.data,
          [action.id]: action.data,
        },
      };
    default:
      return state;
  }
}

function attributionReducer(
    state: WallpaperState['attribution'], action: Actions,
    _: PersonalizationState): WallpaperState['attribution'] {
  switch (action.name) {
    case WallpaperActionName.SET_ATTRIBUTION:
      return action.attribution;
    default:
      return state;
  }
}

function currentSelectedReducer(
    state: WallpaperState['currentSelected'], action: Actions,
    _: PersonalizationState): WallpaperState['currentSelected'] {
  switch (action.name) {
    case WallpaperActionName.SET_SELECTED_IMAGE:
      return action.image;
    default:
      return state;
  }
}

/**
 * Reducer for the pending selected image. The pendingSelected state is set when
 * a user clicks on an image and before the client code is reached.
 *
 * Note: We allow multiple concurrent requests of selecting images while only
 * keeping the latest pending image and failing others occurred in between.
 * The pendingSelected state should not be cleared in this scenario (of multiple
 * concurrent requests). Otherwise, it results in a unwanted jumpy motion of
 * selected state.
 */
function pendingSelectedReducer(
    state: WallpaperState['pendingSelected'], action: Actions,
    globalState: PersonalizationState): WallpaperState['pendingSelected'] {
  switch (action.name) {
    case WallpaperActionName.BEGIN_SELECT_IMAGE:
      return action.image;
    case WallpaperActionName.BEGIN_UPDATE_DAILY_REFRESH_IMAGE:
      return null;
    case WallpaperActionName.SET_SELECTED_IMAGE:
      const {image} = action;
      if (!image) {
        console.warn('pendingSelectedReducer: Failed to get selected image.');
        return null;
      } else if (globalState.wallpaper.loading.setImage == 0) {
        // Clear the pending state when there are no more requests.
        return null;
      }
      return state;
    case WallpaperActionName.SET_FULLSCREEN_STATE:
      if (action.state === FullscreenPreviewState.OFF) {
        // Clear the pending selected state after full screen is dismissed.
        return null;
      }
      return state;
    case WallpaperActionName.END_SELECT_IMAGE:
      const {success} = action;
      if (!success && globalState.wallpaper.loading.setImage <= 1) {
        // Clear the pending selected state if an error occurs and
        // there are no multiple concurrent requests of selecting images.
        return null;
      }
      return state;
    default:
      return state;
  }
}

function dailyRefreshReducer(
    state: WallpaperState['dailyRefresh'], action: Actions,
    _: PersonalizationState): WallpaperState['dailyRefresh'] {
  switch (action.name) {
    case WallpaperActionName.SET_DAILY_REFRESH_COLLECTION_ID:
      return {
        id: action.collectionId,
        type: DailyRefreshType.BACKDROP,
      };
    case WallpaperActionName.SET_GOOGLE_PHOTOS_DAILY_REFRESH_ALBUM_ID:
      return {
        id: action.albumId,
        type: DailyRefreshType.GOOGLE_PHOTOS,
      };
    case WallpaperActionName.CLEAR_DAILY_REFRESH_ACTION:
      return null;
    default:
      return state;
  }
}


function fullscreenReducer(
    state: WallpaperState['fullscreen'], action: Actions,
    _: PersonalizationState): WallpaperState['fullscreen'] {
  switch (action.name) {
    case WallpaperActionName.SET_FULLSCREEN_STATE:
      return action.state;
    default:
      return state;
  }
}

function shouldShowTimeOfDayWallpaperDialogReducer(
    state: boolean, action: Actions, _: PersonalizationState): boolean {
  switch (action.name) {
    case WallpaperActionName.SET_SHOULD_SHOW_TIME_OF_DAY_WALLPAPER_DIALOG:
      return action.shouldShowDialog;
    default:
      return state;
  }
}

function googlePhotosReducer(
    state: WallpaperState['googlePhotos'], action: Actions,
    _: PersonalizationState): WallpaperState['googlePhotos'] {
  switch (action.name) {
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_ALBUM:
      // The list of photos for an album should be loaded only while additional
      // photos exist.
      assert(
          findAlbumById(action.albumId, state.albums) ||
              findAlbumById(action.albumId, state.albumsShared),
          'No matching album id found in Google Photos albums.');
      assert(
          !state.photosByAlbumId[action.albumId] ||
              state.resumeTokens.photosByAlbumId[action.albumId],
          'No photos available in the given Google Photos album.');
      return state;
    case WallpaperActionName.APPEND_GOOGLE_PHOTOS_ALBUM:
      assert(action.albumId !== undefined, 'Album id is undefined.');
      assert(
          action.photos !== undefined,
          'List of Google Photos photos is undefined.');
      assert(
          findAlbumById(action.albumId, state.albums) ||
              findAlbumById(action.albumId, state.albumsShared),
          'No matching album id found in Google Photos albums.');
      // Case: First batch of photos.
      if (!Array.isArray(state.photosByAlbumId[action.albumId])) {
        return {
          ...state,
          photosByAlbumId: {
            ...state.photosByAlbumId,
            [action.albumId]: action.photos,
          },
          resumeTokens: {
            ...state.resumeTokens,
            photosByAlbumId: {
              ...state.resumeTokens.photosByAlbumId,
              [action.albumId]: action.resumeToken,
            },
          },
        };
      }
      // Case: Subsequent batches of photos.
      if (Array.isArray(action.photos)) {
        return {
          ...state,
          photosByAlbumId: {
            ...state.photosByAlbumId,
            [action.albumId]:
                [...state.photosByAlbumId[action.albumId]!, ...action.photos],
          },
          resumeTokens: {
            ...state.resumeTokens,
            photosByAlbumId: {
              ...state.resumeTokens.photosByAlbumId,
              [action.albumId]: action.resumeToken,
            },
          },
        };
      }
      // Case: Error.
      return {
        ...state,
        resumeTokens: {
          ...state.resumeTokens,
          photosByAlbumId: {
            ...state.resumeTokens.photosByAlbumId,
            [action.albumId]: action.resumeToken,
          },
        },
      };
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_ALBUMS:
      // The list of albums should be loaded only while additional albums exist.
      assert(
          !state.albums || state.resumeTokens.albums,
          'Additional owned albums do not exist.');
      return state;
    case WallpaperActionName.APPEND_GOOGLE_PHOTOS_ALBUMS:
      assert(action.albums !== undefined, 'No owned albums fetched.');
      // Case: First batch of albums.
      if (!Array.isArray(state.albums)) {
        return {
          ...state,
          albums: action.albums,
          resumeTokens: {
            ...state.resumeTokens,
            albums: action.resumeToken,
          },
        };
      }
      // Case: Subsequent batches of albums.
      if (Array.isArray(action.albums)) {
        return {
          ...state,
          albums: [...state.albums, ...action.albums],
          resumeTokens: {
            ...state.resumeTokens,
            albums: action.resumeToken,
          },
        };
      }
      // Case: Error.
      return {
        ...state,
        resumeTokens: {
          ...state.resumeTokens,
          albums: action.resumeToken,
        },
      };
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_SHARED_ALBUMS:
      assert(
          !state.albumsShared || state.resumeTokens.albumsShared,
          'Additional shared albums do not exist.');
      return state;
    case WallpaperActionName.APPEND_GOOGLE_PHOTOS_SHARED_ALBUMS:
      assert(action.albums !== undefined, 'No shared albums fetched.');
      // Case: First batch of albums.
      if (!Array.isArray(state.albumsShared)) {
        return {
          ...state,
          albumsShared: action.albums,
          resumeTokens: {
            ...state.resumeTokens,
            albumsShared: action.resumeToken,
          },
        };
      }
      // Case: Subsequent batches of albums.
      if (Array.isArray(action.albums)) {
        return {
          ...state,
          albumsShared: [...state.albumsShared, ...action.albums],
          resumeTokens: {
            ...state.resumeTokens,
            albumsShared: action.resumeToken,
          },
        };
      }
      // Case: Error.
      return {
        ...state,
        resumeTokens: {
          ...state.resumeTokens,
          albumsShared: action.resumeToken,
        },
      };
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_ENABLED:
      // Whether the user is allowed to access Google Photos should be loaded
      // only once.
      assert(state.enabled === undefined);
      return state;
    case WallpaperActionName.SET_GOOGLE_PHOTOS_ENABLED:
      assert(action.enabled !== undefined);
      return {
        ...state,
        enabled: action.enabled,
      };
    case WallpaperActionName.BEGIN_LOAD_GOOGLE_PHOTOS_PHOTOS:
      // The list of photos should be loaded only while additional photos exist.
      assert(!state.photos || state.resumeTokens.photos);
      return state;
    case WallpaperActionName.APPEND_GOOGLE_PHOTOS_PHOTOS:
      assert(action.photos !== undefined);
      // Case: First batch of photos.
      if (!Array.isArray(state.photos)) {
        return {
          ...state,
          photos: action.photos,
          resumeTokens: {
            ...state.resumeTokens,
            photos: action.resumeToken,
          },
        };
      }
      // Case: Subsequent batches of photos.
      if (Array.isArray(action.photos)) {
        return {
          ...state,
          photos: [...state.photos, ...action.photos],
          resumeTokens: {
            ...state.resumeTokens,
            photos: action.resumeToken,
          },
        };
      }
      // Case: Error.
      return {
        ...state,
        resumeTokens: {
          ...state.resumeTokens,
          photos: action.resumeToken,
        },
      };
    default:
      return state;
  }
}

const allSeaPenActionNames =
    new Set<Actions['name']>(Object.values(SeaPenActionName));

function actionIsSeaPenAction(action: Actions): action is SeaPenActions {
  return allSeaPenActionNames.has(action.name);
}

function seaPenReducerAdapter(
    state: SeaPenState, action: Actions, _: PersonalizationState): SeaPenState {
  if (actionIsSeaPenAction(action)) {
    return seaPenReducer(state, action);
  }
  return state;
}

export const wallpaperReducers:
    {[K in keyof WallpaperState]: ReducerFunction<WallpaperState[K]>} = {
      backdrop: backdropReducer,
      loading: loadingReducer,
      local: localReducer,
      attribution: attributionReducer,
      currentSelected: currentSelectedReducer,
      pendingSelected: pendingSelectedReducer,
      dailyRefresh: dailyRefreshReducer,
      fullscreen: fullscreenReducer,
      shouldShowTimeOfDayWallpaperDialog:
          shouldShowTimeOfDayWallpaperDialogReducer,
      googlePhotos: googlePhotosReducer,
      seaPen: seaPenReducerAdapter,
    };