chromium/chrome/browser/resources/settings/site_settings_page/recent_site_permissions.ts

// Copyright 2020 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_elements/policy/cr_tooltip_icon.js';
import 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import '../settings_shared.css.js';
import '../i18n_setup.js';

import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import type {CrTooltipIconElement} from 'chrome://resources/cr_elements/policy/cr_tooltip_icon.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 type {CrTooltipElement} from 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import type {DomRepeatEvent} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {FocusConfig} from '../focus_config.js';
import {routes} from '../route.js';
import type {Route} from '../router.js';
import {RouteObserverMixin, Router} from '../router.js';
import type {ContentSettingsTypes} from '../site_settings/constants.js';
import {AllSitesAction2, ContentSetting, SiteSettingSource} from '../site_settings/constants.js';
import {SiteSettingsMixin} from '../site_settings/site_settings_mixin.js';
import type {RawSiteException, RecentSitePermissions} from '../site_settings/site_settings_prefs_browser_proxy.js';
import {TooltipMixin} from '../tooltip_mixin.js';

import {getTemplate} from './recent_site_permissions.html.js';
import {getLocalizationStringForContentType} from './site_settings_page_util.js';

export interface SettingsRecentSitePermissionsElement {
  $: {
    tooltip: CrTooltipElement,
  };
}

const SettingsRecentSitePermissionsElementBase =
    TooltipMixin(RouteObserverMixin(
        SiteSettingsMixin(WebUiListenerMixin(I18nMixin(PolymerElement)))));

export class SettingsRecentSitePermissionsElement extends
    SettingsRecentSitePermissionsElementBase {
  static get is() {
    return 'settings-recent-site-permissions';
  }

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

  static get properties() {
    return {
      noRecentPermissions: {
        type: Boolean,
        computed: 'computeNoRecentPermissions_(recentSitePermissionsList_)',
        notify: true,
      },

      shouldFocusAfterPopulation_: Boolean,

      /**
       * List of recent site permissions grouped by source.
       */
      recentSitePermissionsList_: {
        type: Array,
        value: () => [],
      },

      focusConfig: {
        type: Object,
        observer: 'focusConfigChanged_',
      },
    };
  }

  noRecentPermissions: boolean;
  private shouldFocusAfterPopulation_: boolean;
  private recentSitePermissionsList_: RecentSitePermissions[];
  focusConfig: FocusConfig;
  private lastSelected_: {origin: string, incognito: boolean, index: number}|
      null;

  constructor() {
    super();

    /**
     * When navigating to a site details sub-page, |lastSelected_| holds the
     * origin and incognito bit associated with the link that sent the user
     * there, as well as the index in recent permission list for that entry.
     * This allows for an intelligent re-focus upon a back navigation.
     */
    this.lastSelected_ = null;
  }

  private focusConfigChanged_(_newConfig: FocusConfig, oldConfig: FocusConfig) {
    // focusConfig is set only once on the parent, so this observer should
    // only fire once.
    assert(!oldConfig);

    this.focusConfig.set(
        routes.SITE_SETTINGS_SITE_DETAILS.path + '_' +
            routes.SITE_SETTINGS.path,
        () => {
          this.shouldFocusAfterPopulation_ = true;
        });
  }

  /**
   * Reload the site recent site permission list whenever the user navigates
   * to the site settings page.
   */
  override currentRouteChanged(currentRoute: Route) {
    if (currentRoute.path === routes.SITE_SETTINGS.path) {
      this.populateList_();
    }
  }

  override ready() {
    super.ready();

    this.addWebUiListener(
        'onIncognitoStatusChanged',
        (hasIncognito: boolean) =>
            this.onIncognitoStatusChanged_(hasIncognito));
    this.browserProxy.updateIncognitoStatus();
  }

  /**
   * @return a user-friendly name for the origin a set of recent permissions
   *     is associated with.
   */
  private getDisplayName_(recentSitePermissions: RecentSitePermissions):
      string {
    return recentSitePermissions.displayName;
  }

  /**
   * @return the site scheme for the origin of a set of recent permissions.
   */
  private getSiteScheme_({origin}: RecentSitePermissions): string {
    const scheme = this.toUrl(origin)!.protocol.slice(0, -1);
    return scheme === 'https' ? '' : scheme;
  }

  /**
   * @return the display text which describes the set of recent permissions.
   */
  private getPermissionsText_({recentPermissions}: RecentSitePermissions):
      string {
    // Recently changed permisisons for a site are grouped into three buckets,
    // each described by a single sentence.
    const groupSentences = [
      this.getPermissionGroupText_(
          'Allowed',
          recentPermissions.filter(
              exception => exception.setting === ContentSetting.ALLOW)),
      this.getPermissionGroupText_(
          'AutoBlocked',
          recentPermissions.filter(
              exception => exception.source === SiteSettingSource.EMBARGO)),
      this.getPermissionGroupText_(
          'Blocked',
          recentPermissions.filter(
              exception => exception.setting === ContentSetting.BLOCK &&
                  exception.source !== SiteSettingSource.EMBARGO)),
    ].filter(string => string.length > 0);

    let finalText = '';
    // The final text may be composed of multiple sentences, so may need the
    // appropriate sentence separators.
    for (const sentence of groupSentences) {
      if (finalText.length > 0) {
        // Whitespace is a valid sentence separator w.r.t i18n.
        finalText += `${this.i18n('sentenceEnd')} ${sentence}`;
      } else {
        finalText = sentence;
      }
    }
    if (groupSentences.length > 1) {
      finalText += this.i18n('sentenceEnd');
    }
    return finalText;
  }

  /**
   * @return the display sentence which groups the provided |exceptions|
   *    together and applies the appropriate description based on |setting|.
   */
  private getPermissionGroupText_(
      setting: string, exceptions: RawSiteException[]): string {
    if (exceptions.length === 0) {
      return '';
    }

    const typeStrings = exceptions.map(exception => {
      const localizationString = getLocalizationStringForContentType(
          exception.type as ContentSettingsTypes);
      return localizationString ? this.i18n(localizationString) : '';
    });

    if (exceptions.length === 1) {
      return this.i18n(`recentPermission${setting}OneItem`, ...typeStrings);
    }
    if (exceptions.length === 2) {
      return this.i18n(`recentPermission${setting}TwoItems`, ...typeStrings);
    }

    return this.i18n(
        `recentPermission${setting}MoreThanTwoItems`, typeStrings[0],
        exceptions.length - 1);
  }

  /**
   * @return the correct CSS class to apply depending on this recent site
   *     permissions entry based on the index.
   */
  private getClassForIndex_(index: number): string {
    return index === 0 ? 'first' : '';
  }

  /**
   * @return true if there are no recent site permissions to display
   */
  private computeNoRecentPermissions_(): boolean {
    return this.recentSitePermissionsList_.length === 0;
  }

  /**
   * Called for 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) {
    // We're only interested in the case where we transition out of incognito
    // and we are currently displaying an incognito entry.
    if (hasIncognito === false &&
        this.recentSitePermissionsList_.some(p => p.incognito)) {
      this.populateList_();
    }
  }

  /**
   * A handler for selecting a recent site permissions entry.
   */
  private onRecentSitePermissionClick_(
      e: DomRepeatEvent<RecentSitePermissions>) {
    const origin = this.recentSitePermissionsList_[e.model.index].origin;
    Router.getInstance().navigateTo(
        routes.SITE_SETTINGS_SITE_DETAILS, new URLSearchParams({site: origin}));
    this.browserProxy.recordAction(AllSitesAction2.ENTER_SITE_DETAILS);
    this.lastSelected_ = {
      index: e.model.index,
      origin: e.model.item.origin,
      incognito: e.model.item.incognito,
    };
  }

  private onShowIncognitoTooltip_(e: Event) {
    e.stopPropagation();

    this.showTooltipAtTarget(this.$.tooltip, e.target! as Element);
  }

  /**
   * Called after the list has finished populating and |lastSelected_| contains
   * a valid entry that should attempt to be focused. If lastSelected_ cannot
   * be found the index where it used to be is focused. This may result in
   * focusing another link arrow, or an incognito information icon. If the
   * recent permission list is empty, focus is lost.
   */
  private focusLastSelected_() {
    if (this.noRecentPermissions) {
      return;
    }
    const currentIndex =
        this.recentSitePermissionsList_.findIndex((permissions) => {
          return permissions.origin === this.lastSelected_!.origin &&
              permissions.incognito === this.lastSelected_!.incognito;
        });

    const fallbackIndex = Math.min(
        this.lastSelected_!.index, this.recentSitePermissionsList_.length - 1);

    const index = currentIndex > -1 ? currentIndex : fallbackIndex;

    if (this.recentSitePermissionsList_[index].incognito) {
      const icon = this.shadowRoot!.querySelector<CrTooltipIconElement>(
          `#incognitoInfoIcon_${index}`);
      assert(!!icon);
      const toFocus = icon.getFocusableElement() as HTMLElement;
      assert(!!toFocus);
      focusWithoutInk(toFocus);
    } else {
      const toFocus = this.shadowRoot!.querySelector<HTMLElement>(
          `#siteEntryButton_${index}`);
      assert(!!toFocus);
      focusWithoutInk(toFocus);
    }
  }

  /**
   * Retrieve the list of recently changed permissions and implicitly trigger
   * the update of the display list.
   */
  private async populateList_() {
    this.recentSitePermissionsList_ =
        await this.browserProxy.getRecentSitePermissions(3);
  }

  /**
   * Called when the dom-repeat DOM has changed. This allows updating the
   * focused element after the elements have been adjusted.
   */
  private onDomChange_() {
    if (this.shouldFocusAfterPopulation_) {
      this.focusLastSelected_();
      this.shouldFocusAfterPopulation_ = false;
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-recent-site-permissions': SettingsRecentSitePermissionsElement;
  }
}

customElements.define(
    SettingsRecentSitePermissionsElement.is,
    SettingsRecentSitePermissionsElement);