chromium/chrome/browser/resources/settings/site_settings/site_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.

/**
 * @fileoverview
 * 'site-list' shows a list of Allowed and Blocked sites for a given
 * category.
 */
import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import '/shared/settings/controls/cr_policy_pref_indicator.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import '../settings_shared.css.js';
import './add_site_dialog.js';
import './edit_exception_dialog.js';
import './site_list_entry.js';

import type {CrTooltipElement} from 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {ListPropertyUpdateMixin} from 'chrome://resources/cr_elements/list_property_update_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/cr_elements/web_ui_listener_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {focusWithoutInk} from 'chrome://resources/js/focus_without_ink.js';
import {sanitizeInnerHtml} from 'chrome://resources/js/parse_html_subset.js';
import type {SanitizeInnerHtmlOpts} from 'chrome://resources/js/parse_html_subset.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {TooltipMixin} from '../tooltip_mixin.js';

import {ContentSetting, ContentSettingsTypes, CookiesExceptionType, INVALID_CATEGORY_SUBTYPE, SITE_EXCEPTION_WILDCARD} from './constants.js';
import {getTemplate} from './site_list.html.js';
import {SiteSettingsMixin} from './site_settings_mixin.js';
import type {RawSiteException, SiteException, SiteSettingsPrefsBrowserProxy} from './site_settings_prefs_browser_proxy.js';
import {SiteSettingsPrefsBrowserProxyImpl} from './site_settings_prefs_browser_proxy.js';

export interface SiteListElement {
  $: {
    addSite: HTMLElement,
    category: HTMLElement,
    listContainer: HTMLElement,
    listHeader: HTMLElement,
    tooltip: CrTooltipElement,
  };
}

const SiteListElementBase = TooltipMixin(ListPropertyUpdateMixin(
    SiteSettingsMixin(WebUiListenerMixin(I18nMixin(PolymerElement)))));

export class SiteListElement extends SiteListElementBase {
  static get is() {
    return 'site-list';
  }

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

  static get properties() {
    return {
      /**
       * Some content types (like Location) do not allow the user to manually
       * edit the exception list from within Settings.
       */
      readOnlyList: {
        type: Boolean,
        value: false,
      },

      categoryHeader: String,

      /**
       * Optional warning message to be displayed bellow the category header.
       */
      systemPermissionWarningKey_: {
        type: String,
        value: null,
        observer: 'attachSystemPermissionSettingsLinkClick_',
      },

      /**
       * The site serving as the model for the currently open action menu.
       */
      actionMenuSite_: Object,

      /**
       * Whether the "edit exception" dialog should be shown.
       */
      showEditExceptionDialog_: Boolean,

      /**
       * Array of sites to display in the widget.
       */
      sites: {
        type: Array,
        value() {
          return [];
        },
      },

      /**
       * The type of category this widget is displaying data for. Normally
       * either 'allow' or 'block', representing which sites are allowed or
       * blocked respectively.
       */
      categorySubtype: {
        type: String,
        value: INVALID_CATEGORY_SUBTYPE,
      },

      /**
       * Filters cookies exceptions based on the type (CookiesExceptionType):
       * - THIRD_PARTY: Only show cookies exceptions that have primary pattern
       * as wildcard (third-party cookies exceptions).
       * - SITE_DATA: Only show cookies exceptions that have primary pattern
       * set. This includes site data exceptions (secondary pattern is wildcard)
       * and exceptions with both patterns set (currently possible only via
       * exceptions API).
       * - COMBINED: Doesn't apply any filters, will show exceptions with both
       * pattern types.
       */
      cookiesExceptionType: String,

      hasIncognito_: Boolean,

      /**
       * Whether to show the Add button next to the header.
       */
      showAddSiteButton_: {
        type: Boolean,
        computed: 'computeShowAddSiteButton_(readOnlyList, category, ' +
            'categorySubtype)',
      },

      showAddSiteDialog_: Boolean,

      /**
       * Whether to show the Allow action in the action menu.
       */
      showAllowAction_: Boolean,

      /**
       * Whether to show the Block action in the action menu.
       */
      showBlockAction_: Boolean,

      /**
       * Whether to show the 'Clear on exit' action in the action
       * menu.
       */
      showSessionOnlyAction_: Boolean,

      /**
       * All possible actions in the action menu.
       */
      actions_: {
        readOnly: true,
        type: Object,
        values: {
          ALLOW: 'Allow',
          BLOCK: 'Block',
          RESET: 'Reset',
          SESSION_ONLY: 'SessionOnly',
        },
      },

      lastFocused_: Object,
      listBlurred_: Boolean,
      tooltipText_: String,
      searchFilter: String,
    };
  }

  static get observers() {
    return ['configureWidget_(category, categorySubtype)'];
  }

  readOnlyList: boolean;
  categoryHeader: string;
  private systemPermissionWarningKey_: string|null;
  private actionMenuSite_: SiteException|null;
  private showEditExceptionDialog_: boolean;
  sites: SiteException[];
  categorySubtype: ContentSetting;
  private hasIncognito_: boolean;
  private showAddSiteButton_: boolean;
  private showAddSiteDialog_: boolean;
  private showAllowAction_: boolean;
  private showBlockAction_: boolean;
  private showSessionOnlyAction_: boolean;
  private lastFocused_: HTMLElement;
  private listBlurred_: boolean;
  private tooltipText_: string;
  searchFilter: string;
  cookiesExceptionType: CookiesExceptionType;

  private activeDialogAnchor_: HTMLElement|null;
  private browserProxy_: SiteSettingsPrefsBrowserProxy =
      SiteSettingsPrefsBrowserProxyImpl.getInstance();

  constructor() {
    super();

    this.updateCategoryWarning_();

    /**
     * The element to return focus to, when the currently active dialog is
     * closed.
     */
    this.activeDialogAnchor_ = null;
  }

  override ready() {
    super.ready();

    this.addWebUiListener(
        'contentSettingSitePermissionChanged',
        (category: ContentSettingsTypes) =>
            this.siteWithinCategoryChanged_(category));
    this.addWebUiListener(
        'contentSettingCategoryChanged',
        (category: ContentSettingsTypes) =>
            this.siteWithinCategoryChanged_(category));
    this.addWebUiListener(
        'onIncognitoStatusChanged',
        (hasIncognito: boolean) =>
            this.onIncognitoStatusChanged_(hasIncognito));
    this.addWebUiListener(
        'osGlobalPermissionChanged', (messages: ContentSettingsTypes[]) => {
          this.setCategoryWarning_(messages.includes(this.category));
        });
    this.browserProxy.updateIncognitoStatus();
  }

  /**
   * Update the category warning when the OS permission for this category
   * changed.
   */
  private updateCategoryWarning_() {
    this.browserProxy.getSystemDeniedPermissions().then(
        (messages: ContentSettingsTypes[]) => {
          this.setCategoryWarning_(messages.includes(this.category));
        });
  }

  /**
   * Sets the category warning when the OS permission for this category changed.
   */
  private setCategoryWarning_(categoryBlocked: boolean) {
    this.set(
        'systemPermissionWarningKey_', ((category: ContentSettingsTypes) => {
          // We return null as warningKey in case the category is not one of
          // the listed, as the warning in case of an OS level block is
          // supported only for camera, microphone and location permissions.
          if (!categoryBlocked) {
            return null;
          }
          switch (category) {
            case ContentSettingsTypes.CAMERA:
              return 'siteSettingsContentCameraBlockedByOs';
            case ContentSettingsTypes.MIC:
              return 'siteSettingsContentMicBlockedByOs';
            case ContentSettingsTypes.GEOLOCATION:
              return 'siteSettingsContentLocationBlockedByOs';
            default:
              return null;
          }
        })(this.category));
  }

  /**
   * Called when a site changes permission.
   * @param category The category of the site that changed.
   */
  private siteWithinCategoryChanged_(category: ContentSettingsTypes) {
    if (category === this.category ||
        (this.category === ContentSettingsTypes.TRACKING_PROTECTION &&
         category === ContentSettingsTypes.COOKIES)) {
      this.configureWidget_();
    }
  }

  /**
   * Called for each site list when incognito is enabled or disabled. Only
   * called on change (opening N incognito windows only fires one message).
   * Another message is sent when the *last* incognito window closes.
   */
  private onIncognitoStatusChanged_(hasIncognito: boolean) {
    this.hasIncognito_ = hasIncognito;

    // The SESSION_ONLY list won't have any incognito exceptions. (Minor
    // optimization, not required).
    if (this.categorySubtype === ContentSetting.SESSION_ONLY) {
      return;
    }

    // A change notification is not sent for each site. So we repopulate the
    // whole list when the incognito profile is created or destroyed.
    this.populateList_();
  }

  /**
   * Configures the action menu, visibility of the widget and shows the list.
   */
  private configureWidget_() {
    if (this.category === undefined) {
      return;
    }

    this.setUpActionMenu_();
    this.populateList_();

    // The Session permissions are only for cookies.
    if (this.categorySubtype === ContentSetting.SESSION_ONLY) {
      this.$.category.hidden = this.category !== ContentSettingsTypes.COOKIES;
    }
  }

  /** Whether there are any site exceptions added for this content setting. */
  private hasSites_(): boolean {
    return this.sites.length > 0;
  }

  /** Whether the header warning should be shown. */
  private showHeaderWarning_(): boolean {
    return this.hasSites_() && (this.systemPermissionWarningKey_ !== null);
  }

  /** The text of the warning. Null if the warning is not to be shown. */
  private getSystemPermissionWarning_(): TrustedHTML {
    const sanitizeOptions: SanitizeInnerHtmlOpts = {tags: ['a'], attrs: ['id']};
    if (this.systemPermissionWarningKey_ !== null) {
      return this.i18nAdvanced(
          this.systemPermissionWarningKey_, sanitizeOptions);
    }
    return sanitizeInnerHtml('');
  }

  /** Attempts to open the system permission settings. */
  private onSystemPermissionSettingsLinkClick_(event: MouseEvent) {
    // Prevents navigation to href='#'.
    event.preventDefault();
    if (this.category !== null) {
      this.browserProxy.openSystemPermissionSettings(this.category);
    }
  }

  /** Attached the click action to the anchor element. */
  private attachSystemPermissionSettingsLinkClick_(): void {
    const elementId = 'openSystemSettingsLink';
    const element: HTMLElement|null|undefined =
        this.shadowRoot?.querySelector(`#${elementId}`);
    if (element !== null && element !== undefined) {
      element!.addEventListener('click', (me: MouseEvent) => {
        this.onSystemPermissionSettingsLinkClick_(me);
      });
    }
  }

  /**
   * Whether the Add Site button is shown in the header for the current category
   * and category subtype.
   */
  private computeShowAddSiteButton_(): boolean {
    return !(
        this.readOnlyList ||
        (this.category === ContentSettingsTypes.FILE_SYSTEM_WRITE &&
         this.categorySubtype === ContentSetting.ALLOW));
  }

  private showNoSearchResults_(): boolean {
    return this.sites.length > 0 && this.getFilteredSites_().length === 0;
  }

  /**
   * A handler for the Add Site button.
   */
  private onAddSiteClick_() {
    assert(!this.readOnlyList);
    this.showAddSiteDialog_ = true;
  }

  private onAddSiteDialogClosed_() {
    this.showAddSiteDialog_ = false;
    focusWithoutInk(this.$.addSite);
  }

  /**
   * Need to use common tooltip since the tooltip in the entry is cut off from
   * the iron-list.
   */
  private onShowTooltip_(e: CustomEvent<{target: HTMLElement, text: string}>) {
    this.tooltipText_ = e.detail.text;
    // cr-tooltip normally determines the target from the |for| property,
    // which is a selector. Here cr-tooltip is being reused by multiple
    // potential targets.
    this.showTooltipAtTarget(this.$.tooltip, e.detail.target);
  }

  /**
   * Populate the sites list for display.
   */
  private populateList_() {
    this.browserProxy_.getExceptionList(this.category).then(exceptionList => {
      this.processExceptions_(exceptionList);
      this.closeActionMenu_();
    });
  }

  /**
   * Process the exception list returned from the native layer.
   */
  private processExceptions_(exceptionList: RawSiteException[]) {
    const sites = exceptionList
                      .filter(
                          site => site.setting !== ContentSetting.DEFAULT &&
                              site.setting === this.categorySubtype)
                      .filter(site => {
                        if (this.category !== ContentSettingsTypes.COOKIES) {
                          return true;
                        }
                        assert(this.cookiesExceptionType !== undefined);
                        switch (this.cookiesExceptionType) {
                          case CookiesExceptionType.THIRD_PARTY:
                            return site.origin === SITE_EXCEPTION_WILDCARD;
                          case CookiesExceptionType.SITE_DATA:
                            // Site data exceptions include all exceptions that
                            // have `origin` set. This includes site data
                            // exceptions and exceptions with both patterns set
                            // (currently possible only via exceptions API).
                            return site.origin !== SITE_EXCEPTION_WILDCARD;
                          case CookiesExceptionType.COMBINED:
                            // For cookies exception type COMBINED, don't apply
                            // any filters and show exceptions with both pattern
                            // types.
                            return true;
                        }
                      })
                      .map(site => this.expandSiteException(site));
    this.updateList('sites', x => x.origin, sites);
  }

  /**
   * Set up the values to use for the action menu.
   */
  private setUpActionMenu_() {
    this.showAllowAction_ = this.categorySubtype !== ContentSetting.ALLOW;
    this.showBlockAction_ = this.categorySubtype !== ContentSetting.BLOCK;
    this.showSessionOnlyAction_ =
        this.categorySubtype !== ContentSetting.SESSION_ONLY &&
        this.category === ContentSettingsTypes.COOKIES;
  }

  /**
   * @return Whether to show the "Session Only" menu item for the currently
   *     active site.
   */
  private showSessionOnlyActionForSite_(): boolean {
    // It makes no sense to show "clear on exit" for exceptions that only apply
    // to incognito. It gives the impression that they might under some
    // circumstances not be cleared on exit, which isn't true.
    if (!this.actionMenuSite_ || this.actionMenuSite_.incognito) {
      return false;
    }

    return this.showSessionOnlyAction_;
  }

  private setContentSettingForActionMenuSite_(contentSetting: ContentSetting) {
    assert(this.actionMenuSite_);
    this.browserProxy.setCategoryPermissionForPattern(
        this.actionMenuSite_!.origin, this.actionMenuSite_!.embeddingOrigin,
        this.category, contentSetting, this.actionMenuSite_!.incognito);
  }

  private onAllowClick_() {
    // Removing the last visible item should focus the list's header.
    const shouldMoveFocus = this.getFilteredSites_().length === 1;
    this.setContentSettingForActionMenuSite_(ContentSetting.ALLOW);
    this.closeActionMenu_();
    if (shouldMoveFocus) {
      this.$.listHeader.focus();
    }
  }

  private onBlockClick_() {
    // Removing the last visible item should focus the list's header.
    const shouldMoveFocus = this.getFilteredSites_().length === 1;
    this.setContentSettingForActionMenuSite_(ContentSetting.BLOCK);
    this.closeActionMenu_();
    if (shouldMoveFocus) {
      this.$.listHeader.focus();
    }
  }

  private onSessionOnlyClick_() {
    this.setContentSettingForActionMenuSite_(ContentSetting.SESSION_ONLY);
    this.closeActionMenu_();
  }

  private onEditClick_() {
    // Close action menu without resetting |this.actionMenuSite_| since it is
    // bound to the dialog.
    this.shadowRoot!.querySelector('cr-action-menu')!.close();
    this.showEditExceptionDialog_ = true;
  }

  private onEditExceptionDialogClosed_() {
    this.showEditExceptionDialog_ = false;
    this.actionMenuSite_ = null;
    if (this.activeDialogAnchor_) {
      this.activeDialogAnchor_.focus();
      this.activeDialogAnchor_ = null;
    }
  }

  private onResetClick_() {
    // Removing the last visible item should focus the list's header.
    const shouldMoveFocus = this.getFilteredSites_().length === 1;
    assert(this.actionMenuSite_);
    this.browserProxy.resetCategoryPermissionForPattern(
        this.actionMenuSite_.origin, this.actionMenuSite_.embeddingOrigin,
        this.category, this.actionMenuSite_.incognito);
    this.closeActionMenu_();
    if (shouldMoveFocus) {
      this.$.listHeader.focus();
    }
  }

  private onShowActionMenu_(
      e: CustomEvent<{anchor: HTMLElement, model: SiteException}>) {
    this.activeDialogAnchor_ = e.detail.anchor;
    this.actionMenuSite_ = e.detail.model;
    this.shadowRoot!.querySelector('cr-action-menu')!.showAt(
        this.activeDialogAnchor_);
  }

  private onResetEntry_() {
    // Removing the last visible item should focus the list's header.
    if (this.getFilteredSites_().length === 1) {
      this.$.listHeader.focus();
    }
  }

  private closeActionMenu_() {
    this.actionMenuSite_ = null;
    this.activeDialogAnchor_ = null;
    const actionMenu = this.shadowRoot!.querySelector('cr-action-menu')!;
    if (actionMenu.open) {
      actionMenu.close();
    }
  }

  private getFilteredSites_(): SiteException[] {
    if (!this.searchFilter) {
      return this.sites.slice();
    }

    type SearchableProperty = 'displayName'|'origin'|'embeddingOrigin';
    const propNames: SearchableProperty[] =
        ['displayName', 'origin', 'embeddingOrigin'];
    const searchFilter = this.searchFilter.toLowerCase();
    return this.sites.filter(
        site => propNames.some(
            propName => site[propName].toLowerCase().includes(searchFilter)));
  }

  private getAddButtonLabel_(): string {
    if (this.categorySubtype === ContentSetting.ALLOW) {
      return this.i18n('siteDataPageAddSiteToAllowListLabel');
    } else if (this.categorySubtype === ContentSetting.BLOCK) {
      return this.i18n('siteDataPageAddSiteToBlockListLabel');
    } else {
      return '';
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'site-list': SiteListElement;
  }
}

customElements.define(SiteListElement.is, SiteListElement);