chromium/ui/file_manager/file_manager/foreground/js/empty_folder_controller.ts

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {sanitizeInnerHtml} from 'chrome://resources/js/parse_html_subset.js';

import type {VolumeInfo} from '../../background/js/volume_info.js';
import {queryRequiredElement} from '../../common/js/dom_utils.js';
import {getODFSMetadataQueryEntry, isInteractiveVolume, isOneDrive, isOneDrivePlaceholderKey, isRecentRootType} from '../../common/js/entry_utils.js';
import type {FakeEntry} from '../../common/js/files_app_entry_types.js';
import {isSkyvaultV2Enabled} from '../../common/js/flags.js';
import {str} from '../../common/js/translations.js';
import {FileErrorToDomError} from '../../common/js/util.js';
import {RootType} from '../../common/js/volume_manager_types.js';
import {myFilesEntryListKey, recentRootKey, updateIsInteractiveVolume} from '../../state/ducks/volumes.js';
import type {FileKey, State, Volume, VolumeId} from '../../state/state.js';
import {getStore} from '../../state/store.js';
import type {Store} from '../../state/store.js';

import {FSP_ACTION_HIDDEN_ONEDRIVE_ACCOUNT_STATE, FSP_ACTION_HIDDEN_ONEDRIVE_REAUTHENTICATION_REQUIRED, FSP_ACTION_HIDDEN_ONEDRIVE_USER_EMAIL, ODFS_EXTENSION_ID} from './constants.js';
import type {DirectoryModel} from './directory_model.js';
import type {ProvidersModel} from './providers_model.js';

/**
 * The empty state image for the Recents folder.
 */
const RECENTS_EMPTY_FOLDER =
    'foreground/images/files/ui/empty_folder.svg#empty_folder';

/**
 * The image shown when search returned no results.
 */
const SEARCH_EMPTY_RESULTS =
    'foreground/images/files/ui/empty_search_results.svg#empty_search_results';

/**
 * The empty state image for the Trash folder.
 */
const TRASH_EMPTY_FOLDER =
    'foreground/images/files/ui/empty_trash_folder.svg#empty_trash_folder';

/**
 * The reauthentication required image for ODFS. There are no files when
 * reauthentication is required (scan fails).
 */
const ODFS_REAUTHENTICATION_REQUIRED = 'foreground/images/files/ui/' +
    'odfs_reauthentication_required.svg#odfs_reauthentication_required';

/**
 * The frozen account image for ODFS. There are no files when the account
 * is frozen (scan fails).
 */
const ODFS_FROZEN_ACCOUNT = 'foreground/images/files/ui/' +
    'odfs_frozen_account.svg#odfs_frozen_account';

/**
 * The state of the user's OneDrive account. Matches the enum in ODFS.
 */
enum OdfsAccountState {
  NORMAL = 'NORMAL',
  REAUTHENTICATION_REQUIRED = 'REAUTHENTICATION_REQUIRED',
  FROZEN_ACCOUNT = 'FROZEN_ACCOUNT',
}

interface OdfsMetadata {
  userEmail?: string;
  // TODO(b/330786891): Remove reauthenticationRequired once no longer needed
  // for backwards compatibility with ODFS.
  reauthenticationRequired?: boolean;
  accountState?: OdfsAccountState;
}

export type ScanFailedEvent = CustomEvent<{error: DOMError}>;

/**
 * Empty folder controller which controls the empty folder element inside
 * the file list container.
 */
export class EmptyFolderController {
  private image_: HTMLElement;
  private hasFilesystemError_: boolean = false;
  private store_: Store = getStore();
  private volumes_: (Record<VolumeId, Volume>)|null = null;
  private uiEntries_: FileKey[] = [];
  private localUserFilesAllowed_: boolean|undefined = true;
  protected isScanning_ = false;
  protected label_: HTMLElement;

  constructor(
      private emptyFolder_: HTMLElement,
      private directoryModel_: DirectoryModel,
      private providersModel_: ProvidersModel,
      private recentEntry_: FakeEntry) {
    this.label_ = queryRequiredElement('.label', this.emptyFolder_);
    this.image_ = queryRequiredElement('.image', this.emptyFolder_);
    this.store_.subscribe(this);

    this.directoryModel_.addEventListener(
        'cur-dir-scan-started', this.onScanStarted_.bind(this));
    this.directoryModel_.addEventListener(
        'cur-dir-scan-failed', this.onScanFailed_.bind(this));
    this.directoryModel_.addEventListener(
        'cur-dir-scan-canceled', this.onScanFinished.bind(this));
    this.directoryModel_.addEventListener(
        'cur-dir-scan-completed', this.onScanFinished.bind(this));
    this.directoryModel_.addEventListener(
        'cur-dir-rescan-completed', this.onScanFinished.bind(this));
  }

  /**
   * Checks whether the volumes or fake entries changed, and sets an error state
   * if there are no volumes except Recent to show.
   */
  async onStateChanged(state: State) {
    if (!isSkyvaultV2Enabled()) {
      return;
    }

    const localUserFilesAllowed = state.preferences?.localUserFilesAllowed;
    if (this.volumes_ !== state.volumes ||
        this.uiEntries_ !== state.uiEntries ||
        this.localUserFilesAllowed_ !== localUserFilesAllowed) {
      // MyFiles might still be present as an UI entry, in case the migration is
      // in process.
      const filteredUIEntries = state.uiEntries.filter(
          uiEntryKey => uiEntryKey !== myFilesEntryListKey &&
              uiEntryKey !== recentRootKey);
      if (Object.values(state.volumes).length === 0 &&
          filteredUIEntries.length === 0 && localUserFilesAllowed === false) {
        this.showFilesystemError_();
      } else {
        this.resetUi_(/*setImageVisible*/ true);
        this.hasFilesystemError_ = false;
      }
      this.volumes_ = state.volumes;
      this.uiEntries_ = state.uiEntries;
      this.localUserFilesAllowed_ = localUserFilesAllowed;
    }
  }

  /**
   * Handles scan start.
   */
  private onScanStarted_() {
    this.isScanning_ = true;
    this.updateUi_();
  }

  /**
   * Queries ODFS for the ODFS metadata using the special root actions request.
   */
  private async getOdfsMetadata_(odfsVolumeInfo: VolumeInfo):
      Promise<OdfsMetadata> {
    // Request ODFS root actions to get ODFS metadata.
    return new Promise((fulfill) => {
      chrome.fileManagerPrivate.getCustomActions(
          [getODFSMetadataQueryEntry(odfsVolumeInfo) as DirectoryEntry],
          (customActions:
               chrome.fileManagerPrivate.FileSystemProviderAction[]) => {
            const metadata = {} as OdfsMetadata;
            if (chrome.runtime.lastError) {
              console.error(
                  'Unexpectedly failed to fetch custom actions for ODFS ' +
                  'root because of: ' + chrome.runtime.lastError.message);
              fulfill(metadata);
              return;
            }
            // Find the reauthentication required action.
            for (const action of customActions) {
              switch (action.id) {
                case FSP_ACTION_HIDDEN_ONEDRIVE_USER_EMAIL:
                  metadata.userEmail = action.title;
                  continue;
                case FSP_ACTION_HIDDEN_ONEDRIVE_REAUTHENTICATION_REQUIRED:
                  metadata.reauthenticationRequired = action.title === 'true';
                  continue;
                case FSP_ACTION_HIDDEN_ONEDRIVE_ACCOUNT_STATE:
                  if (!action.title) {
                    continue;
                  }
                  switch (action.title) {
                    case OdfsAccountState.NORMAL:
                      metadata.accountState = OdfsAccountState.NORMAL;
                      continue;
                    case OdfsAccountState.REAUTHENTICATION_REQUIRED:
                      metadata.accountState =
                          OdfsAccountState.REAUTHENTICATION_REQUIRED;
                      continue;
                    case OdfsAccountState.FROZEN_ACCOUNT:
                      metadata.accountState = OdfsAccountState.FROZEN_ACCOUNT;
                      continue;
                    default:
                      continue;
                  }
                default:
                  continue;
              }
            }
            fulfill(metadata);
          });
    });
  }

  /**
   * Handles scan fail. If the scan failed for the ODFS volume due to
   * reauthenticaton being required or the user having a frozen account, set the
   * state of the volume as not interactive and set the correct svg to display.
   */
  protected onScanFailed_(event: ScanFailedEvent) {
    this.isScanning_ = false;
    const currentVolumeInfo = this.directoryModel_.getCurrentVolumeInfo();
    if (!currentVolumeInfo) {
      this.updateUi_();
      return;
    }
    // If scan did not fail for ODFS, return.
    if (!isOneDrive(currentVolumeInfo)) {
      this.updateUi_();
      return;
    }
    // If the error is not NO_MODIFICATION_ALLOWED_ERR or QUOTA_EXCEEDED_ERR,
    // return. This is equivalent to the ACCESS_DENIED and NO_SPACE error
    // respectively thrown by ODFS.
    if (event.detail.error.name !==
            FileErrorToDomError.NO_MODIFICATION_ALLOWED_ERR &&
        event.detail.error.name !== FileErrorToDomError.QUOTA_EXCEEDED_ERR) {
      this.updateUi_();
      return;
    }
    // Set the svg and set ODFS to non-interactive if the ACCESS_DENIED was due
    // to reauthentication being required rather than some other access error.
    this.getOdfsMetadata_(currentVolumeInfo)
        .then((odfsMetadata: OdfsMetadata) => {
          let svgRef = null;
          // TODO(b/330786891): Remove odfsMetadata.reauthenticationRequired
          // once no longer needed for backwards compatibility with ODFS.
          if ((odfsMetadata.reauthenticationRequired ||
               odfsMetadata.accountState ===
                   OdfsAccountState.REAUTHENTICATION_REQUIRED) &&
              event.detail.error.name ===
                  FileErrorToDomError.NO_MODIFICATION_ALLOWED_ERR) {
            svgRef = ODFS_REAUTHENTICATION_REQUIRED;
          } else if (
              odfsMetadata.accountState === OdfsAccountState.FROZEN_ACCOUNT &&
              event.detail.error.name ===
                  FileErrorToDomError.QUOTA_EXCEEDED_ERR) {
            svgRef = ODFS_FROZEN_ACCOUNT;
          }
          if (svgRef !== null && isInteractiveVolume(currentVolumeInfo)) {
            getStore().dispatch(updateIsInteractiveVolume({
              volumeId: currentVolumeInfo.volumeId,
              isInteractive: false,
            }));
          }
          this.updateUi_(svgRef);
        });
  }

  /**
   * Handles scan finish.
   */
  onScanFinished() {
    const currentVolumeInfo = this.directoryModel_.getCurrentVolumeInfo();
    if (currentVolumeInfo && isOneDrive(currentVolumeInfo)) {
      if (!isInteractiveVolume(currentVolumeInfo)) {
        // Set |isInteractive| to true for ODFS when in an authenticated state.
        getStore().dispatch(updateIsInteractiveVolume({
          volumeId: currentVolumeInfo.volumeId,
          isInteractive: true,
        }));
      }
    }
    this.isScanning_ = false;
    this.updateUi_();
  }

  /**
   * Shows the given message. It may consist of just the `title`, or
   * `title` and `description`.
   */
  private showMessage_(title: string, description?: string) {
    if (!description) {
      this.label_.appendChild(document.createTextNode(title));
      return;
    }

    const titleSpan = document.createElement('span');
    titleSpan.id = 'empty-folder-title';
    titleSpan.innerText = title;
    const descSpan = document.createElement('span');
    descSpan.innerText = description;
    this.label_.appendChild(titleSpan);
    this.label_.appendChild(document.createElement('br'));
    this.label_.appendChild(descSpan);
  }

  /**
   * Shows the ODFS reauthentication required message. Includes the "Sign in"
   * link and sets the handler.
   */
  private showOdfsReauthenticationMessage_() {
    const titleSpan = document.createElement('span');
    titleSpan.id = 'empty-folder-title';
    titleSpan.innerText = str('ONEDRIVE_LOGGED_OUT_TITLE');

    const text = document.createElement('span');
    text.innerText = str('ONEDRIVE_SIGN_IN_SUBTITLE');

    const signInLink = document.createElement('a');
    signInLink.setAttribute('class', 'sign-in');
    signInLink.innerText = str('ONEDRIVE_SIGN_IN_LINK');
    signInLink.addEventListener('click', this.onOdfsSignIn_.bind(this));

    const descSpan = document.createElement('span');
    descSpan.id = 'empty-folder-desc';
    descSpan.appendChild(text);
    descSpan.appendChild(document.createElement('br'));
    descSpan.appendChild(signInLink);

    this.label_.appendChild(titleSpan);
    this.label_.appendChild(document.createElement('br'));
    this.label_.appendChild(descSpan);
  }

  /**
   * Shows the ODFS frozen account message.
   */
  private showOdfsFrozenAccountMessage_() {
    const titleSpan = document.createElement('span');
    titleSpan.id = 'empty-folder-title';
    titleSpan.innerText = str('ONEDRIVE_FROZEN_ACCOUNT_TITLE');

    const descSpan = document.createElement('span');
    descSpan.id = 'empty-folder-desc';
    descSpan.innerHTML =
        sanitizeInnerHtml(str('ONEDRIVE_FROZEN_ACCOUNT_SUBTITLE'));

    this.label_.appendChild(titleSpan);
    this.label_.appendChild(document.createElement('br'));
    this.label_.appendChild(descSpan);
  }

  /**
   * Called when "Sign in" link for ODFS reauthentication is clicked. Request
   * a new ODFS mount. ODFS will unmount the old mount if the authentication is
   * successful in the new mount.
   */
  private onOdfsSignIn_() {
    const currentVolumeInfo = this.directoryModel_.getCurrentVolumeInfo();
    if (currentVolumeInfo && isOneDrive(currentVolumeInfo) &&
        currentVolumeInfo.providerId !== undefined) {
      this.providersModel_.requestMount(currentVolumeInfo.providerId);
    } else if (isOneDrivePlaceholderKey(
                   this.directoryModel_.getCurrentFileKey())) {
      this.providersModel_.requestMount(ODFS_EXTENSION_ID);
    }
  }

  /**
   * Shows the message informing the user that the file system isn't available,
   * for example because the admin misconfigured SkyVault policies.
   */
  private showFilesystemError_() {
    if (!isSkyvaultV2Enabled()) {
      return;
    }
    this.resetUi_(/*setImageVisible*/ false);

    this.image_.hidden = true;
    this.emptyFolder_.hidden = false;
    this.showMessage_(str('SKYVAULT_MISCONFIGURED_POLICY_MESSAGE'));
    this.label_.classList.add('no-image');

    this.hasFilesystemError_ = true;
  }

  /**
   * Resets the empty folder UI.
   * @param setImageVisible Whether the image element should be visible.
   */
  private resetUi_(setImageVisible: boolean) {
    this.label_.innerText = '';
    this.emptyFolder_.hidden = true;

    if (setImageVisible) {
      this.image_.hidden = false;
      this.label_.classList.remove('no-image');
    } else {
      this.image_.hidden = true;
      this.label_.classList.add('no-image');
    }
  }

  /**
   * Updates visibility of empty folder UI.
   */
  protected updateUi_(svgRef: string|null = null) {
    if (this.hasFilesystemError_) {
      return;
    }
    const currentRootType = this.directoryModel_.getCurrentRootType();

    if (isRecentRootType(currentRootType)) {
      svgRef = RECENTS_EMPTY_FOLDER;
    } else if (currentRootType === RootType.TRASH) {
      svgRef = TRASH_EMPTY_FOLDER;
    } else if (isOneDrivePlaceholderKey(
                   this.directoryModel_.getCurrentFileKey())) {
      svgRef = ODFS_REAUTHENTICATION_REQUIRED;
    } else {
      const {search} = getStore().getState();
      if (search && search.query) {
        svgRef = SEARCH_EMPTY_RESULTS;
      }
    }

    const fileListModel = this.directoryModel_.getFileList();

    this.resetUi_(/*setImageVisible*/ true);
    if (svgRef === null || this.isScanning_ ||
        (fileListModel && fileListModel.length > 0)) {
      return;
    }

    const svgUseElement =
        this.image_.querySelector<SVGUseElement>('.image > svg > use')!;
    svgUseElement.setAttributeNS(
        'http://www.w3.org/1999/xlink', 'xlink:href', svgRef);
    this.emptyFolder_.hidden = false;

    if (svgRef === TRASH_EMPTY_FOLDER) {
      this.showMessage_(
          str('EMPTY_TRASH_FOLDER_TITLE'), str('EMPTY_TRASH_FOLDER_DESC'));
      return;
    }

    if (svgRef === ODFS_REAUTHENTICATION_REQUIRED) {
      this.showOdfsReauthenticationMessage_();
      return;
    }

    if (svgRef === ODFS_FROZEN_ACCOUNT) {
      this.showOdfsFrozenAccountMessage_();
      return;
    }

    if (svgRef === SEARCH_EMPTY_RESULTS) {
      this.showMessage_(
          str('SEARCH_NO_MATCHING_RESULTS_TITLE'),
          str('SEARCH_NO_MATCHING_RESULTS_DESC'));
      return;
    }

    switch (this.recentEntry_.fileCategory) {
      case chrome.fileManagerPrivate.FileCategory.AUDIO:
        this.showMessage_(str('RECENT_EMPTY_AUDIO_FOLDER'));
        break;
      case chrome.fileManagerPrivate.FileCategory.DOCUMENT:
        this.showMessage_(str('RECENT_EMPTY_DOCUMENTS_FOLDER'));
        break;
      case chrome.fileManagerPrivate.FileCategory.IMAGE:
        this.showMessage_(str('RECENT_EMPTY_IMAGES_FOLDER'));
        break;
      case chrome.fileManagerPrivate.FileCategory.VIDEO:
        this.showMessage_(str('RECENT_EMPTY_VIDEOS_FOLDER'));
        break;
      default:
        this.showMessage_(str('RECENT_EMPTY_FOLDER'));
    }
  }
}