chromium/ash/webui/common/resources/sea_pen/sea_pen_controller.ts

// Copyright 2023 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 {QUERY, SeaPenImageId} from './constants.js';
import {isSeaPenTextInputEnabled} from './load_time_booleans.js';
import {MantaStatusCode, SeaPenFeedbackMetadata, SeaPenProviderInterface, SeaPenQuery, SeaPenThumbnail} from './sea_pen.mojom-webui.js';
import * as seaPenAction from './sea_pen_actions.js';
import {logSeaPenImageSet} from './sea_pen_metrics_logger.js';
import {SeaPenStoreInterface} from './sea_pen_store.js';
import {isNonEmptyArray, isPersonalizationApp} from './sea_pen_utils.js';
import {withMinimumDelay} from './transition.js';

export async function selectRecentSeaPenImage(
    id: SeaPenImageId, provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  if (id === store.data.currentSelected) {
    // Return if the just selected image is already the current image.
    return;
  }
  // Batch these changes together to reduce polymer churn as multiple state
  // fields change quickly.
  store.beginBatchUpdate();
  store.dispatch(seaPenAction.beginSelectRecentSeaPenImageAction(id));
  store.dispatch(seaPenAction.beginLoadSelectedRecentSeaPenImageAction());
  store.endBatchUpdate();

  const shouldPreview = await shouldShowFullscreenPreview(provider);
  if (shouldPreview) {
    provider.makeTransparent();
    store.dispatch(seaPenAction.setSeaPenFullscreenStateAction(
        FullscreenPreviewState.LOADING));
  }
  const {success} = await provider.selectRecentSeaPenImage(id, shouldPreview);

  store.beginBatchUpdate();
  store.dispatch(seaPenAction.endSelectRecentSeaPenImageAction(id, success));
  if (!success) {
    console.warn('Error setting image');
    store.dispatch(seaPenAction.setSeaPenFullscreenStateAction(
        FullscreenPreviewState.OFF));
    // Revert back to the old one.
    store.dispatch(seaPenAction.setSelectedRecentSeaPenImageAction(
        store.data.currentSelected));
  }
  store.endBatchUpdate();

  if (success) {
    const isTextQuery =
        !!store.data.recentImageData[id]?.imageInfo?.query?.textQuery;
    logSeaPenImageSet(isTextQuery, /*source=*/ 'Recent');
  }
}

export async function getSeaPenThumbnails(
    query: SeaPenQuery, provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  store.dispatch(seaPenAction.beginSearchSeaPenThumbnailsAction(query));
  store.dispatch(seaPenAction.setCurrentSeaPenQueryAction(query));
  const {thumbnails, statusCode} =
      await withMinimumDelay(provider.getSeaPenThumbnails(query));
  if (!isNonEmptyArray(thumbnails) || statusCode !== MantaStatusCode.kOk) {
    console.warn('Error generating thumbnails. Status code: ', statusCode);
  }

  // New requests might have been made for a different template so we should
  // only return results for the request that matches the template that the user
  // is using or when the user is not viewing the template to clear the loading
  // UI.
  // TODO(b/333924681): Implement a better way to handle the race condition.
  // The current logic does not handle the case the user lands on the original
  // template after navigating between different templates.
  const params = new URLSearchParams(window.location.search);
  const templateIdParam = params.get('seaPenTemplateId');
  if (!templateIdParam ||
      (templateIdParam === query.templateQuery?.id.toString()) ||
      (templateIdParam === QUERY && !!query.textQuery)) {
    store.dispatch(
        seaPenAction.setThumbnailResponseStatusCodeAction(statusCode));
    store.dispatch(seaPenAction.setSeaPenThumbnailsAction(query, thumbnails));
  }
}

export async function selectSeaPenThumbnail(
    thumbnail: SeaPenThumbnail, provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  let promise: ReturnType<SeaPenProviderInterface['selectSeaPenThumbnail']>;

  store.dispatch(seaPenAction.beginSelectSeaPenThumbnailAction(thumbnail));

  const shouldPreview = await shouldShowFullscreenPreview(provider);
  if (shouldPreview) {
    provider.makeTransparent();
    store.dispatch(seaPenAction.setSeaPenFullscreenStateAction(
        FullscreenPreviewState.LOADING));
  }
  if (isPersonalizationApp()) {
    promise = withMinimumDelay(
        provider.selectSeaPenThumbnail(thumbnail.id, shouldPreview));
  } else {
    // VC Background should not start the visual loading state immediately. The
    // async request will resolve very quickly.
    store.beginBatchUpdate();
    promise = provider.selectSeaPenThumbnail(thumbnail.id, shouldPreview);
  }

  const {success} = await promise;

  store.beginBatchUpdate();
  store.dispatch(
      seaPenAction.endSelectSeaPenThumbnailAction(thumbnail, success));

  if (!success) {
    store.dispatch(seaPenAction.setSeaPenFullscreenStateAction(
        FullscreenPreviewState.OFF));
    // Revert back to the original one.
    store.dispatch(seaPenAction.setSelectedRecentSeaPenImageAction(
        store.data.currentSelected));
  }
  store.endBatchUpdate();
  // Re-fetches the recent SeaPen image if setting SeaPen thumbnail
  // successfully, which means the file has been downloaded successfully.
  if (success) {
    const isTextQuery = !!store.data.currentSeaPenQuery?.textQuery;
    logSeaPenImageSet(isTextQuery, /*source=*/ 'Create');
    await fetchRecentSeaPenData(provider, store);
  }
}

export async function clearSeaPenThumbnails(store: SeaPenStoreInterface) {
  store.dispatch(seaPenAction.clearSeaPenThumbnailsAction());
}

export async function cleanUpSeaPenQueryStates(store: SeaPenStoreInterface) {
  store.beginBatchUpdate();
  store.dispatch(seaPenAction.setThumbnailResponseStatusCodeAction(null));
  store.dispatch(seaPenAction.clearCurrentSeaPenQueryAction());
  store.dispatch(seaPenAction.clearSeaPenThumbnailsLoadingAction());
  store.endBatchUpdate();
}

export async function deleteRecentSeaPenImage(
    id: SeaPenImageId, provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  const {success} = await provider.deleteRecentSeaPenImage(id);
  // Re-fetches the recent Sea Pen images if recent Sea Pen image is removed
  // successfully.
  if (success) {
    fetchRecentSeaPenData(provider, store);
  }
}

export async function getRecentSeaPenImageIds(
    provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  store.dispatch(seaPenAction.beginLoadRecentSeaPenImagesAction());

  const {ids} = await provider.getRecentSeaPenImageIds();
  if (ids == null) {
    console.warn('Failed to fetch recent sea pen images');
  }

  store.dispatch(seaPenAction.setRecentSeaPenImagesAction(ids));
}

/**
 * Gets list of recent Sea Pen images, then fetches image data for each recent
 * Sea Pen image.
 */
export async function fetchRecentSeaPenData(
    provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  // Do not restart loading local image list if a load is already in progress.
  if (!store.data.loading.recentImages) {
    await getRecentSeaPenImageIds(provider, store);
  }
  await getMissingRecentSeaPenImageData(provider, store);
}

/**
 * Because data loading can happen asynchronously and is triggered
 * on page load and on window focus, multiple "threads" can be fetching
 * data simultaneously. Synchronize them with a task queue.
 */
const recentSeaPenImageDataToFetch = new Set<SeaPenImageId>();

/**
 * Get an sea pen data one at a time for every recent Sea Pen image that does
 * not have the data yet.
 */
async function getMissingRecentSeaPenImageData(
    provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  if (!Array.isArray(store.data.recentImages)) {
    console.warn('Cannot fetch thumbnails with invalid image list');
    return;
  }
  // Set correct loading state for each image thumbnail. Do in a batch update to
  // reduce number of times that polymer must re-render.
  store.beginBatchUpdate();
  for (const id of store.data.recentImages) {
    if (store.data.recentImageData[id] ||
        store.data.loading.recentImageData[id] ||
        recentSeaPenImageDataToFetch.has(id)) {
      // Do not re-load thumbnail if already present, or already loading.
      continue;
    }
    recentSeaPenImageDataToFetch.add(id);
    store.dispatch(seaPenAction.beginLoadRecentSeaPenImageDataAction(id));
  }
  store.endBatchUpdate();

  // There may be multiple async tasks triggered that pull off this queue.
  while (recentSeaPenImageDataToFetch.size) {
    await Promise.all(Array.from(recentSeaPenImageDataToFetch).map(async id => {
      recentSeaPenImageDataToFetch.delete(id);
      const {thumbnailData} = await provider.getRecentSeaPenImageThumbnail(id);
      if (!thumbnailData) {
        console.warn('Failed to fetch recent Sea Pen image data', id);
      }
      store.dispatch(
          seaPenAction.setRecentSeaPenImageDataAction(id, thumbnailData));
    }));
  }
}

export function openFeedbackDialog(
    metadata: SeaPenFeedbackMetadata, provider: SeaPenProviderInterface) {
  provider.openFeedbackDialog(metadata);
}

export async function getShouldShowSeaPenIntroductionDialog(
    provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  const {shouldShowDialog} =
      await provider.shouldShowSeaPenIntroductionDialog();

  // Dispatch action to set the should show dialog boolean.
  store.dispatch(seaPenAction.setShouldShowSeaPenIntroductionDialogAction(
      shouldShowDialog));
}

export async function closeSeaPenIntroductionDialog(
    provider: SeaPenProviderInterface,
    store: SeaPenStoreInterface): Promise<void> {
  if (!store.data.shouldShowSeaPenIntroductionDialog) {
    // Do nothing if the introduction dialog is already closed;
    return;
  }

  await provider.handleSeaPenIntroductionDialogClosed();

  // Dispatch action to set the should show dialog boolean.
  store.dispatch(
      seaPenAction.setShouldShowSeaPenIntroductionDialogAction(false));
}

/**
 * Check whether to show fullscreen preview while selecting a SeaPen image
 * as wallpaper.
 */
async function shouldShowFullscreenPreview(provider: SeaPenProviderInterface):
    Promise<boolean> {
  if (!isPersonalizationApp() || !isSeaPenTextInputEnabled()) {
    return false;
  }
  const {tabletMode} = await provider.isInTabletMode();
  return tabletMode;
}