chromium/chrome/browser/resources/extensions/item_list.ts

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

import 'chrome://resources/cr_components/managed_footnote/managed_footnote.js';
import './item.js';
import './mv2_deprecation_panel.js';
import './shared_style.css.js';
import './review_panel.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {ExtensionsItemElement, ItemDelegate} from './item.js';
import {getTemplate} from './item_list.html.js';
import {getMv2ExperimentStage, Mv2ExperimentStage} from './mv2_deprecation_util.js';

type Filter = (info: chrome.developerPrivate.ExtensionInfo) => boolean;

const ExtensionsItemListElementBase = I18nMixin(PolymerElement);

export class ExtensionsItemListElement extends ExtensionsItemListElementBase {
  static get is() {
    return 'extensions-item-list';
  }

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

  static get properties() {
    return {
      apps: Array,
      extensions: Array,
      delegate: Object,

      inDevMode: {
        type: Boolean,
        value: false,
      },

      isMv2DeprecationNoticeDismissed: {
        type: Boolean,
        notify: true,
      },

      filter: {
        type: String,
      },

      computedFilter_: {
        type: String,
        computed: 'computeFilter_(filter)',
        observer: 'announceSearchResults_',
      },

      maxColumns_: {
        type: Number,
        value: 3,
      },

      /**
       * List of potentially unsafe extensions that should be visible in the
       * review panel.
       */
      unsafeExtensions_: {
        type: Array,
        computed: 'computeUnsafeExtensions_(extensions.*)',
      },

      /**
       * Current Manifest V2 experiment stage.
       */
      mv2ExperimentStage_: {
        type: Number,
        value: () => getMv2ExperimentStage(
            loadTimeData.getInteger('MV2ExperimentStage')),
      },

      /**
       * List of extensions that are affected by the mv2 deprecation and should
       * be visible in the mv2 deprecation panel.
       */
      mv2DeprecatedExtensions_: {
        type: Array,
        computed: 'computeMv2DeprecatedExtensions_(extensions.*)',
      },

      shownAppsCount_: {
        type: Number,
        value: 0,
      },

      shownExtensionsCount_: {
        type: Number,
        value: 0,
      },

      /**
       * Indicates whether the review panel is shown.
       */
      showSafetyCheckReviewPanel_: {
        type: Boolean,
        computed: 'computeShowSafetyCheckReviewPanel_(unsafeExtensions_)',
      },

      /**
       * Indicates if the review panel has ever been shown.
       */
      reviewPanelShown_: {
        type: Boolean,
        value: false,
      },

      /*
       * Indicates whether the mv2 deprecation panel is shown.
       */
      showMv2DeprecationPanel_: {
        type: Boolean,
        computed: 'computeShowMv2DeprecationPanel_(' +
            'mv2ExperimentStage_, mv2DeprecatedExtensions_, ' +
            'isMv2DeprecationNoticeDismissed)',
      },

      hasSafetyCheckTriggeringExtension_: {
        type: Boolean,
        computed: 'computeHasSafetyCheckTriggeringExtension_(extensions)',
      },
    };
  }

  apps: chrome.developerPrivate.ExtensionInfo[];
  extensions: chrome.developerPrivate.ExtensionInfo[];
  delegate: ItemDelegate;
  inDevMode: boolean;
  isMv2DeprecationNoticeDismissed: boolean;
  filter: string;
  private computedFilter_: string;
  private maxColumns_: number;
  private unsafeExtensions_: chrome.developerPrivate.ExtensionInfo[];
  private mv2ExperimentStage_: Mv2ExperimentStage;
  private mv2DeprecatedExtensions_: chrome.developerPrivate.ExtensionInfo[];
  private shownAppsCount_: number;
  private shownExtensionsCount_: number;
  private showMv2DeprecationPanel_: boolean;
  private showSafetyCheckReviewPanel_: boolean;
  private reviewPanelShown_: boolean;
  private hasSafetyCheckTriggeringExtension_: boolean;

  getDetailsButton(id: string): HTMLElement|null {
    const item =
        this.shadowRoot!.querySelector<ExtensionsItemElement>(`#${id}`);
    return item && item.getDetailsButton();
  }

  getRemoveButton(id: string): HTMLElement|null {
    const item =
        this.shadowRoot!.querySelector<ExtensionsItemElement>(`#${id}`);
    return item && item.getRemoveButton();
  }

  getErrorsButton(id: string): HTMLElement|null {
    const item =
        this.shadowRoot!.querySelector<ExtensionsItemElement>(`#${id}`);
    return item && item.getErrorsButton();
  }

  /**
   * Focus the remove button for the item matching `id`. If the remove button is
   * not visible, focus the details button instead.
   * return: If an item's button has been focused, see comment below.
   */
  focusItemButton(id: string): boolean {
    const item =
        this.shadowRoot!.querySelector<ExtensionsItemElement>(`#${id}`);
    // This function is called from a setTimeout() inside manager.ts. Rarely,
    // the list of extensions rendered in this element may not match the list of
    // extensions stored in manager.ts for a brief moment (not visible to the
    // user). As a result, `item` here may be null even though `id` points to
    // an extension inside `manager.ts`. If this happens, do not focus anything.
    // Observed in crbug.com/1482580.
    if (!item) {
      return false;
    }

    const buttonToFocus = item.getRemoveButton() || item.getDetailsButton();
    buttonToFocus!.focus();
    return true;
  }

  /**
   * Computes the filter function to be used for determining which items
   * should be shown. A |null| value indicates that everything should be
   * shown.
   * return {?Function}
   */
  private computeFilter_(): Filter|null {
    const formattedFilter = this.filter.trim().toLowerCase();
    if (!formattedFilter) {
      return null;
    }

    return i => [i.name, i.id].some(
               s => s.toLowerCase().includes(formattedFilter));
  }

  /**
   * Computes the extensions that are affected by the manifest v2 deprecation
   * and should be visible in the MV2 deprecation panel.
   */
  private computeMv2DeprecatedExtensions_():
      chrome.developerPrivate.ExtensionInfo[] {
    return this.extensions.filter((extension) => {
      switch (this.mv2ExperimentStage_) {
        case Mv2ExperimentStage.NONE:
          return false;
        case Mv2ExperimentStage.WARNING:
          return extension.isAffectedByMV2Deprecation &&
              !extension.didAcknowledgeMV2DeprecationNotice;
        case Mv2ExperimentStage.DISABLE_WITH_REENABLE:
          return extension.isAffectedByMV2Deprecation &&
              extension.disableReasons.unsupportedManifestVersion &&
              !extension.didAcknowledgeMV2DeprecationNotice;
      }
    });
  }

  /**
   * Computes the extensions that are potentially unsafe and should be visible
   * in the review panel.
   */
  private computeUnsafeExtensions_(): chrome.developerPrivate.ExtensionInfo[] {
    return this.extensions?.filter(
        extension =>
            !!(extension.safetyCheckText &&
               extension.safetyCheckText.panelString));
  }

  /**
   * Returns whether the review deprecation panel should be visible.
   */
  private computeShowSafetyCheckReviewPanel_(): boolean {
    // Panel is hidden if neither safety feature is on.
    if (!loadTimeData.getBoolean('safetyCheckShowReviewPanel') &&
        !loadTimeData.getBoolean('safetyHubShowReviewPanel')) {
      return false;
    }

    // If there are any unsafe extensions, they will be shown in the panel.
    // Store this, so we can show the completion info in the panel when there
    // are no unsafe extensions left after the user finished reviewing the
    // extensions.
    // Note: Unsafe extensions may not be initialized at construction, thus we
    // check for their existence.
    if (this.unsafeExtensions_?.length !== 0) {
      this.reviewPanelShown_ = true;
    }

    // Panel is visible if there are any unsafe extensions, or the there are
    // none left after the user finished reviewing the extensions.
    // Note: Unsafe extensions may not be initialized at construction, thus we
    // check for their existence.
    return this.unsafeExtensions_?.length !== 0 || this.reviewPanelShown_;
  }

  private computeHasSafetyCheckTriggeringExtension_(): boolean {
    if (!this.extensions) {
      return false;
    }
    for (const extension of this.extensions) {
      if (!!extension.safetyCheckText &&
          !!extension.safetyCheckText.panelString &&
          this.showSafetyCheckReviewPanel_) {
        return true;
      }
    }
    return false;
  }

  /**
   * Returns whether the manifest v2 deprecation panel should be visible.
   */
  private computeShowMv2DeprecationPanel_(): boolean {
    switch (this.mv2ExperimentStage_) {
      case Mv2ExperimentStage.NONE:
        return false;
      case Mv2ExperimentStage.WARNING:
      case Mv2ExperimentStage.DISABLE_WITH_REENABLE:
        // Panel is visible when it has not been dismissed and at least one
        // extension is affected by the MV2 deprecation.
        return !this.isMv2DeprecationNoticeDismissed &&
            this.mv2DeprecatedExtensions_?.length !== 0;
    }
  }

  private shouldShowEmptyItemsMessage_() {
    if (!this.apps || !this.extensions) {
      return;
    }

    return this.apps.length === 0 && this.extensions.length === 0;
  }

  private shouldShowEmptySearchMessage_() {
    return !this.shouldShowEmptyItemsMessage_() && this.shownAppsCount_ === 0 &&
        this.shownExtensionsCount_ === 0;
  }

  private onNoExtensionsClick_(e: Event) {
    if ((e.target as HTMLElement).tagName === 'A') {
      chrome.metricsPrivate.recordUserAction('Options_GetMoreExtensions');
    }
  }

  private announceSearchResults_() {
    if (this.computedFilter_) {
      setTimeout(() => {  // Async to allow list to update.
        const total = this.shownAppsCount_ + this.shownExtensionsCount_;
        getAnnouncerInstance().announce(this.shouldShowEmptySearchMessage_() ?
            this.i18n('noSearchResults') :
            (total === 1 ?
                 this.i18n('searchResultsSingular', this.filter) :
                 this.i18n(
                     'searchResultsPlural', total.toString(), this.filter)));
      }, 0);
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'extensions-item-list': ExtensionsItemListElement;
  }
}

customElements.define(ExtensionsItemListElement.is, ExtensionsItemListElement);