chromium/chrome/browser/resources/settings/site_settings/site_entry.ts

// Copyright 2018 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-entry' is an element representing a single eTLD+1 site entity.
 */
import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/cr_elements/cr_collapse/cr_collapse.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import '../settings_shared.css.js';
import '../site_favicon.js';

import type {CrCollapseElement} from 'chrome://resources/cr_elements/cr_collapse/cr_collapse.js';
import type {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import type {CrLazyRenderElement} from 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import {FocusRowMixin} from 'chrome://resources/cr_elements/focus_row_mixin.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import type {DomRepeatEvent} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {afterNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BaseMixin} from '../base_mixin.js';
import {loadTimeData} from '../i18n_setup.js';
import {routes} from '../route.js';
import {Router} from '../router.js';

import {AllSitesAction2, SortMethod} from './constants.js';
import {getTemplate} from './site_entry.html.js';
import {SiteSettingsMixin} from './site_settings_mixin.js';
import type {OriginInfo, SiteGroup} from './site_settings_prefs_browser_proxy.js';


export interface SiteEntryElement {
  $: {
    expandIcon: CrIconButtonElement,
    collapseParent: HTMLElement,
    cookies: HTMLElement,
    rwsMembership: HTMLElement,
    displayName: HTMLElement,
    originList: CrLazyRenderElement<CrCollapseElement>,
    toggleButton: HTMLElement,
    extensionIdDescription: HTMLElement,
  };
}

const SiteEntryElementBase =
    FocusRowMixin(BaseMixin(SiteSettingsMixin(I18nMixin(PolymerElement))));

export class SiteEntryElement extends SiteEntryElementBase {
  static get is() {
    return 'site-entry';
  }

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

  static get properties() {
    return {
      /**
       * An object representing a group of sites with the same eTLD+1.
       */
      siteGroup: {
        type: Object,
        observer: 'onSiteGroupChanged_',
      },

      /**
       * The name to display beside the icon. If grouped_() is true, it will be
       * the eTLD+1 for all the origins. For Isolated Web Apps instead of
       * displaying the origin, the short name of the app will be displayed.
       * Otherwise, it will return the host.
       */
      displayName_: String,

      /**
       * The string to display when there is a non-zero number of cookies.
       */
      cookieString_: String,

      /**
       * The related website set info for a site including owner and members
       * count.
       */
      rwsMembershipLabel_: {
        type: String,
        value: '',
      },

      /**
       * Mock preference used to power managed policy icon for related website
       * sets.
       */
      rwsEnterprisePref_: Object,

      /**
       * Whether site entry is shown with a related website set filter search.
       */
      isRwsFiltered: Boolean,

      /**
       * The position of this site-entry in its parent list.
       */
      listIndex: {
        type: Number,
        value: -1,
      },

      /**
       * The string to display showing the overall usage of this site-entry.
       */
      overallUsageString_: String,

      /**
       * An array containing the strings to display showing the individual disk
       * usage for each origin in |siteGroup|.
       */
      originUsages_: {
        type: Array,
        value() {
          return [];
        },
      },

      /**
       * An array containing the strings to display showing the individual
       * cookies number for each origin in |siteGroup|.
       */
      cookiesNum_: {
        type: Array,
        value() {
          return [];
        },
      },

      /**
       * The selected sort method.
       */
      sortMethod: {type: String, observer: 'updateOrigins_'},
    };
  }

  static get observers() {
    return [
      'updateRwsMembershipLabel_(siteGroup.rwsNumMembers, siteGroup.rwsOwner)',
      'updatePolicyPref_(siteGroup.rwsEnterpriseManaged)',
      'updateFocus_(siteGroup.rwsOwner)',
    ];
  }

  siteGroup: SiteGroup;
  private displayName_: string;
  private cookieString_: string;
  private rwsMembershipLabel_: string;
  isRwsFiltered: boolean;
  listIndex: number;
  private overallUsageString_: string;
  private originUsages_: string[];
  private cookiesNum_: string[];
  sortMethod?: SortMethod;
  private rwsEnterprisePref_: chrome.settingsPrivate.PrefObject;

  private button_: Element|null = null;
  private eventTracker_: EventTracker = new EventTracker();

  override disconnectedCallback() {
    super.disconnectedCallback();

    if (this.button_) {
      this.eventTracker_.remove(this.button_, 'keydown');
    }
  }

  private onButtonKeydown_(e: KeyboardEvent) {
    if (e.shiftKey && e.key === 'Tab') {
      this.focus();
    }
  }

  /**
   * Whether the list of origins displayed in this site-entry is a group of
   * eTLD+1 origins or not.
   * @param siteGroup The eTLD+1 group of origins.
   */
  private grouped_(siteGroup: SiteGroup): boolean {
    if (!siteGroup) {
      return false;
    }
    if (siteGroup.origins.length > 1 ||
        siteGroup.numCookies > siteGroup.origins[0].numCookies ||
        siteGroup.origins.some(o => o.isPartitioned)) {
      return true;
    }
    return false;
  }

  /**
   * Returns a user-friendly name for the siteGroup.
   * @param siteGroup The group of origins.
   * @return The user-friendly name.
   */
  private siteGroupRepresentation_(siteGroup: SiteGroup): string {
    if (!siteGroup) {
      return '';
    }
    return siteGroup.displayName;
  }

  /**
   * @param siteGroup The eTLD+1 group of origins.
   */
  private onSiteGroupChanged_(siteGroup: SiteGroup) {
    // Update the button listener.
    if (this.button_) {
      this.eventTracker_.remove(this.button_, 'keydown');
    }
    this.button_ =
        this.shadowRoot!.querySelector('#toggleButton *:not([hidden])');
    assert(this.button_);
    this.eventTracker_.add(
        this.button_, 'keydown',
        (e: KeyboardEvent) => this.onButtonKeydown_(e));

    if (!this.grouped_(siteGroup)) {
      // Ensure ungrouped |siteGroup|s do not get stuck in an opened state.
      const collapseChild = this.$.originList.getIfExists();
      if (collapseChild && collapseChild.opened) {
        this.toggleCollapsible_();
      }
    }
    if (!siteGroup) {
      return;
    }
    this.calculateUsageInfo_(siteGroup);
    this.getCookieNumString_(siteGroup.numCookies).then(string => {
      this.cookieString_ = string;
    });
    this.updateOrigins_(this.sortMethod);
    this.displayName_ = this.siteGroupRepresentation_(siteGroup);
  }

  /**
   * Returns any non-HTTPS scheme/protocol for the siteGroup that only contains
   * one origin. Otherwise, returns a empty string.
   * @param siteGroup The eTLD+1 group of origins.
   * @return The scheme if non-HTTPS, or empty string if HTTPS.
   */
  private siteGroupScheme_(siteGroup: SiteGroup): string {
    if (!siteGroup || (this.grouped_(siteGroup))) {
      return '';
    }
    return this.originScheme_(siteGroup.origins[0]);
  }

  /**
   * Returns any non-HTTPS scheme/protocol for the origin. Otherwise, returns
   * an empty string.
   * @return The scheme if non-HTTPS, or empty string if HTTPS.
   */
  private originScheme_(origin: OriginInfo): string {
    const url = this.toUrl(origin.origin)!;
    const scheme = url.protocol.replace(new RegExp(':*$'), '');
    const HTTPS_SCHEME = 'https';
    if (scheme === HTTPS_SCHEME) {
      return '';
    }
    return scheme;
  }

  /**
   * Get an appropriate favicon that represents this group of eTLD+1 sites as a
   * whole.
   * @param siteGroup The eTLD+1 group of origins.
   * @return URL that is used for fetching the favicon
   */
  private getSiteGroupIcon_(siteGroup: SiteGroup): string {
    const origins = siteGroup.origins;
    assert(origins);
    assert(origins.length >= 1);
    if (origins.length === 1) {
      return origins[0].origin;
    }
    // If we can find a origin with format "www.etld+1", use the favicon of this
    // origin. Otherwise find the origin with largest storage, and use the
    // number of cookies as a tie breaker.
    for (const originInfo of origins) {
      if (siteGroup.etldPlus1 &&
          this.toUrl(originInfo.origin)!.host ===
              'www.' + siteGroup.etldPlus1) {
        return originInfo.origin;
      }
    }
    const getMaxStorage = (max: OriginInfo, originInfo: OriginInfo) => {
      return (
          max.usage > originInfo.usage ||
                  (max.usage === originInfo.usage &&
                   max.numCookies > originInfo.numCookies) ?
              max :
              originInfo);
    };
    return origins.reduce(getMaxStorage, origins[0]).origin;
  }

  /**
   * Calculates the amount of disk storage used by the given eTLD+1.
   * Also updates the corresponding display strings.
   * @param siteGroup The eTLD+1 group of origins.
   */
  private calculateUsageInfo_(siteGroup: SiteGroup) {
    let overallUsage = 0;
    siteGroup.origins.forEach(originInfo => {
      overallUsage += originInfo.usage;
    });
    this.browserProxy.getFormattedBytes(overallUsage).then(string => {
      this.overallUsageString_ = string;
    });
  }

  private isRwsMember_(): boolean {
    return !!this.siteGroup && this.siteGroup.rwsOwner !== undefined;
  }

  /**
   * Evaluates whether the three dot menu should be shown for the site entry.
   * @returns True if site group is a related website set member and filter by
   * related website set owner is not applied.
   */
  private shouldShowOverflowMenu(): boolean {
    return this.isRwsMember_() && !this.isRwsFiltered;
  }

  /**
   * Get display string for number of cookies.
   */
  private getCookieNumString_(numCookies: number): Promise<string> {
    if (numCookies === 0) {
      return Promise.resolve('');
    }
    return this.browserProxy.getNumCookiesString(numCookies);
  }

  /**
   * Updates the display string for RWS information of owner and member count.
   * @param rwsNumMembers The number of members in the related website set.
   * @param rwsOwner The eTLD+1 for the related website set owner.
   */
  private updateRwsMembershipLabel_() {
    if (!this.siteGroup.rwsOwner) {
      this.rwsMembershipLabel_ = '';
    } else {
      this.browserProxy
          .getRwsMembershipLabel(
              this.siteGroup.rwsNumMembers!, this.siteGroup.rwsOwner!)
          .then(label => this.rwsMembershipLabel_ = label);
    }
  }

  /**
   * Evaluates whether the policy icon should be shown.
   * @returns True when `this.siteGroup.rwsEnterpriseManaged` is true,
   * otherwise false.
   */
  private shouldShowPolicyPrefIndicator_(): boolean {
    return !!this.siteGroup.rwsEnterpriseManaged;
  }

  /**
   * Updates `rwsEnterprisePref_` based on `siteGroup.rwsEnterpriseManaged`.
   */
  private updatePolicyPref_() {
    this.rwsEnterprisePref_ = this.siteGroup.rwsEnterpriseManaged ?
        Object.assign({
          enforcement: chrome.settingsPrivate.Enforcement.ENFORCED,
          controlledBy: chrome.settingsPrivate.ControlledBy.DEVICE_POLICY,
        }) :
        Object.assign({
          enforcement: undefined,
          controlledBy: undefined,
        });
  }

  private updateFocus_() {
    // TODO(crbug.com/40875159): Re-focusing a changed entry (such as when an
    // entry is removed from list) happens before the entry elements have been
    // updated (e.g. different buttons shown / hidden). This causes the
    // focusRowMixin to incorrectly identify an element which is about to be
    // hidden / removed as a valid focus target.
    const isCurrentlyFocused = this.isFocused;
    afterNextRender(this, () => {
      if (isCurrentlyFocused) {
        (this.shouldShowOverflowMenu() ?
             this.$$<CrIconButtonElement>('#rwsOverflowMenuButton') :
             this.$$<CrIconButtonElement>('#removeSiteButton'))!.focus();
      }
    });
  }

  /**
   * Array binding for the |originUsages_| array for use in the HTML.
   * @param change The change record for the array.
   * @param index The index of the array item.
   */
  private originUsagesItem_(change: {base: string[]}, index: number): string {
    return change.base[index];
  }

  /**
   * Array binding for the |cookiesNum_| array for use in the HTML.
   * @param change The change record for the array.
   * @param index The index of the array item.
   */
  private originCookiesItem_(change: {base: string[]}, index: number): string {
    return change.base[index];
  }

  /**
   * Navigates to the corresponding Site Details page for the given origin.
   * @param origin The origin to navigate to the Site Details page for it.
   */
  private navigateToSiteDetails_(origin: string) {
    this.fire(
        'site-entry-selected', {item: this.siteGroup, index: this.listIndex});
    Router.getInstance().navigateTo(
        routes.SITE_SETTINGS_SITE_DETAILS,
        new URLSearchParams('site=' + origin));
  }

  /**
   * A handler for selecting a site (by clicking on the origin).
   */
  private onOriginClick_(e: DomRepeatEvent<OriginInfo>) {
    if (this.siteGroup.origins[e.model.index].isPartitioned) {
      return;
    }
    this.navigateToSiteDetails_(this.siteGroup.origins[e.model.index].origin);
    this.browserProxy.recordAction(AllSitesAction2.ENTER_SITE_DETAILS);
    chrome.metricsPrivate.recordUserAction('AllSites_EnterSiteDetails');
  }

  /**
   * A handler for clicking on a site-entry heading. This will either show a
   * list of origins or directly navigates to Site Details if there is only one.
   */
  private onSiteEntryClick_() {
    // Individual origins don't expand - just go straight to Site Details.
    if (!this.grouped_(this.siteGroup)) {
      this.navigateToSiteDetails_(this.siteGroup.origins[0].origin);
      this.browserProxy.recordAction(AllSitesAction2.ENTER_SITE_DETAILS);
      chrome.metricsPrivate.recordUserAction('AllSites_EnterSiteDetails');
      return;
    }
    this.toggleCollapsible_();

    // Make sure the expanded origins can be viewed without further scrolling
    // (in case |this| is already at the bottom of the viewport).
    this.scrollIntoViewIfNeeded();
  }

  /**
   * Toggles open and closed the list of origins if there is more than one.
   */
  private toggleCollapsible_() {
    const collapseChild = this.$.originList.get();
    collapseChild.toggle();
    this.$.toggleButton.setAttribute(
        'aria-expanded', collapseChild.opened ? 'true' : 'false');
    this.$.expandIcon.setAttribute(
        'aria-expanded', collapseChild.opened ? 'true' : 'false');
    this.$.expandIcon.classList.toggle('icon-expand-more');
    this.$.expandIcon.classList.toggle('icon-expand-less');
    this.fire('iron-resize');
  }

  /**
   * Fires a custom event when the menu button is clicked. Sends the details
   * of the site entry item and where the menu should appear.
   */
  private showOverflowMenu_(e: Event) {
    this.fire('open-menu', {
      target: e.target,
      index: this.listIndex,
      item: this.siteGroup,
      origin: (e.target as HTMLElement).dataset['origin'],
      isPartitioned: (e.target as HTMLElement).dataset['partitioned'],
      actionScope: (e.target as HTMLElement).dataset['context'],
    });
  }

  private onRemove_(e: Event) {
    this.fire('remove-site', {
      target: e.target,
      index: this.listIndex,
      item: this.siteGroup,
      origin: (e.target as HTMLElement).dataset['origin'],
      isPartitioned:
          (e.target as HTMLElement).dataset['partitioned'] !== undefined,
      actionScope: (e.target as HTMLElement).dataset['context'],
    });
  }

  /**
   * Returns the correct class to apply depending on this site-entry's position
   * in a list.
   */
  private getClassForIndex_(index: number): string {
    return index > 0 ? 'hr' : '';
  }

  private getSubpageLabel_(target: string): string {
    return this.i18n(
        'siteSettingsSiteDetailsSubpageAccessibilityLabel', target);
  }

  private getRemoveOriginButtonTitle_(origin: string): string {
    return this.i18n(
        'siteSettingsCookieRemoveSite', this.originRepresentation(origin));
  }

  private getMoreActionsLabel_(): string {
    return this.i18n(
        'relatedWebsiteSetsMoreActionsTitle', this.siteGroup.displayName);
  }
  /**
   * Update the order and data display text for origins.
   */
  private updateOrigins_(sortMethod?: SortMethod) {
    if (!sortMethod || !this.siteGroup || !this.grouped_(this.siteGroup)) {
      return;
    }

    const origins = this.siteGroup.origins.slice();
    origins.sort(this.sortFunction_(sortMethod));
    this.set('siteGroup.origins', origins);

    this.originUsages_ = new Array(origins.length);
    origins.forEach((originInfo, i) => {
      this.browserProxy.getFormattedBytes(originInfo.usage).then((string) => {
        this.set(`originUsages_.${i}`, string);
      });
    });

    this.cookiesNum_ = new Array(this.siteGroup.origins.length);
    origins.forEach((originInfo, i) => {
      this.getCookieNumString_(originInfo.numCookies).then((string) => {
        this.set(`cookiesNum_.${i}`, string);
      });
    });
  }

  /**
   * Sort functions for sorting origins based on selected method.
   */
  private sortFunction_(sortMethod: SortMethod):
      (o1: OriginInfo, o2: OriginInfo) => number {
    if (sortMethod === SortMethod.MOST_VISITED) {
      return (origin1, origin2) => {
        return (origin1.isPartitioned ? 1 : 0) -
            (origin2.isPartitioned ? 1 : 0) ||
            origin2.engagement - origin1.engagement;
      };
    } else if (sortMethod === SortMethod.STORAGE) {
      return (origin1, origin2) => {
        return (origin1.isPartitioned ? 1 : 0) -
            (origin2.isPartitioned ? 1 : 0) ||
            origin2.usage - origin1.usage ||
            origin2.numCookies - origin1.numCookies;
      };
    } else if (sortMethod === SortMethod.NAME) {
      return (origin1, origin2) => {
        return (origin1.isPartitioned ? 1 : 0) -
            (origin2.isPartitioned ? 1 : 0) ||
            origin1.origin.localeCompare(origin2.origin);
      };
    }
    assertNotReached();
  }

  /**
   * Get extension id description string for an extension |siteGroup|.
   */
  private extensionIdDescription_(siteGroup: SiteGroup): string {
    const id = this.originRepresentation(siteGroup.origins[0].origin);
    return loadTimeData.getStringF('siteSettingsExtensionIdDescription', id);
  }

  /**
   * Check if the given |siteGroup| is an extension.
   */
  private isExtension_(siteGroup: SiteGroup): boolean {
    return this.siteGroupScheme_(siteGroup) === 'chrome-extension';
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'site-entry': SiteEntryElement;
  }
}

customElements.define(SiteEntryElement.is, SiteEntryElement);