chromium/chrome/browser/resources/settings/safety_hub/unused_site_permissions_module.ts

// Copyright 2023 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/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import '../i18n_setup.js';
import '../icons.html.js';
import '../privacy_icons.html.js';
import './safety_hub_module.js';

import type {CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrToastElement} from 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/cr_elements/web_ui_listener_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';
import {isUndoKeyboardEvent} from 'chrome://resources/js/util.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {loadTimeData} from '../i18n_setup.js';
import type {MetricsBrowserProxy} from '../metrics_browser_proxy.js';
import {MetricsBrowserProxyImpl, SafetyCheckUnusedSitePermissionsModuleInteractions} from '../metrics_browser_proxy.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 {SiteSettingsMixin} from '../site_settings/site_settings_mixin.js';
import {getLocalizationStringForContentType} from '../site_settings_page/site_settings_page_util.js';
import {TooltipMixin} from '../tooltip_mixin.js';

import type {SafetyHubBrowserProxy, UnusedSitePermissions} from './safety_hub_browser_proxy.js';
import {SafetyHubBrowserProxyImpl, SafetyHubEvent} from './safety_hub_browser_proxy.js';
import type {SettingsSafetyHubModuleElement, SiteInfo} from './safety_hub_module.js';
import {getTemplate} from './unused_site_permissions_module.html.js';

export interface SettingsSafetyHubUnusedSitePermissionsModuleElement {
  $: {
    headerActionMenu: CrActionMenuElement,
    bulkUndoButton: HTMLElement,
    gotItButton: HTMLElement,
    goToSettings: HTMLElement,
    module: SettingsSafetyHubModuleElement,
    moreActionButton: HTMLElement,
    toastUndoButton: HTMLElement,
    undoToast: CrToastElement,
  };
}

/** Actions the user can perform to review their unused site permissions. */
enum Action {
  ALLOW_AGAIN,
  GOT_IT,
}

/**
 * Information about unused site permissions with an additional detail field to
 * extend SiteInfo.
 */
interface UnusedSitePermissionsDisplay extends UnusedSitePermissions, SiteInfo {
  detail: string;
}

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

export class SettingsSafetyHubUnusedSitePermissionsModuleElement extends
    SettingsSafetyHubUnusedSitePermissionsModuleElementBase {
  static get is() {
    return 'settings-safety-hub-unused-site-permissions';
  }

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

  static get properties() {
    return {
      // The string for the primary header label.
      headerString_: String,

      // Text below primary header label.
      subheaderString_: String,

      // The icon next to primary header label.
      headerIconString_: String,

      // Most recent site permissions the user has allowed again.
      lastUnusedSitePermissionsAllowedAgain_: {
        type: Object,
        value: null,
      },

      // Most recent site permissions list the user has acknowledged.
      lastUnusedSitePermissionsListAcknowledged_: {
        type: Array,
        value: null,
      },

      // Sites that have already been rendered. Any new ones not listed here
      // will need to be explicitly animated to show.
      renderedOrigins_: {
        type: Array,
        value: [],
      },

      // Last action the user has taken, determines the function of the undo
      // button in the toast.
      lastUserAction_: {
        type: Object,
        value: null,
      },

      // List of unused sites where permissions have been removed. This list
      // being null indicates it has not loaded yet.
      sites_: {
        type: Array,
        value: null,
        observer: 'onSitesChanged_',
      },

      // The text that will be shown in the undo toast element.
      toastText_: String,

      // Indicates whether user has finished the review process.
      shouldShowCompletionInfo_: {
        type: Boolean,
        computed: 'computeShouldShowCompletionInfo_(sites_.*)',
      },

      // Indicates whether the abusive notification revocation feature
      // is enabled.
      safetyHubAbusiveNotificationRevocationEnabled_: {
        type: Boolean,
        value: () => loadTimeData.getBoolean(
            'safetyHubAbusiveNotificationRevocationEnabled'),
      },
    };
  }

  private headerString_: string;
  private subheaderString_: string|null;
  private headerIconString_: string;
  private toastText_: string|null;
  private sites_: UnusedSitePermissionsDisplay[]|null;
  private shouldShowCompletionInfo_: boolean;
  private safetyHubAbusiveNotificationRevocationEnabled_: boolean;
  private lastUnusedSitePermissionsAllowedAgain_: UnusedSitePermissions|null;
  private lastUnusedSitePermissionsListAcknowledged_: UnusedSitePermissions[]|
      null;
  private renderedOrigins_: string[];
  private lastUserAction_: Action|null;
  private eventTracker_: EventTracker = new EventTracker();
  private browserProxy_: SafetyHubBrowserProxy =
      SafetyHubBrowserProxyImpl.getInstance();
  private metricsBrowserProxy_: MetricsBrowserProxy =
      MetricsBrowserProxyImpl.getInstance();


  override async connectedCallback() {
    this.addWebUiListener(
        SafetyHubEvent.UNUSED_PERMISSIONS_MAYBE_CHANGED,
        (sites: UnusedSitePermissions[]) =>
            this.onUnusedSitePermissionListChanged_(sites));

    const sites =
        await this.browserProxy_.getRevokedUnusedSitePermissionsList();

    this.onUnusedSitePermissionListChanged_(sites);
    // This should be called after the sites have been retrieved such that
    // currentRouteChanged is called afterwards.
    super.connectedCallback();
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    this.eventTracker_.removeAll();
  }

  override currentRouteChanged(currentRoute: Route) {
    if (currentRoute !== routes.SAFETY_HUB) {
      // Remove event listener when navigating away from the page.
      this.eventTracker_.removeAll();
      return;
    }

    if (this.sites_ !== null) {
      this.metricsBrowserProxy_
          .recordSafetyHubUnusedSitePermissionsModuleListCountHistogram(
              this.sites_.length);
    }

    this.eventTracker_.add(
        document, 'keydown', (e: Event) => this.onKeyDown_(e as KeyboardEvent));
  }

  /**
   * Text that describes which permissions have been revoked for an origin.
   * Permissions are listed explicitly when there are up to and including 3.
   * For 4 or more, the two first permissions are listed explicitly and for
   * the remaining ones a count is shown, e.g. 'and 2 more'.
   */
  private getPermissionsText_(permissions: ContentSettingsTypes[]): string {
    assert(
        permissions.length > 0,
        'There is no permission for the user to review.');

    const permissionsI18n = permissions.map(permission => {
      const localizationString =
          getLocalizationStringForContentType(permission);
      return localizationString ? this.i18n(localizationString) : '';
    });

    // Unused notifications are not auto-revoked, so if the permissions
    // include notifications, then the revocation is for an abusive site.
    // In this case, we want to use the specific string for revoked abusive
    // notifications.
    if (this.safetyHubAbusiveNotificationRevocationEnabled_ &&
        permissionsI18n
            .map(permission => {
              return permission.toLowerCase();
            })
            .includes('notifications')) {
      return this.i18n(
          'safetyHubAbusiveNotificationPermissionsSettingSublabel');
    }

    switch (permissionsI18n.length) {
      case 1:
        return this.i18n(
            this.safetyHubAbusiveNotificationRevocationEnabled_ ?
                'safetyHubUnusedSitePermissionsRemovedOnePermissionLabel' :
                'safetyCheckUnusedSitePermissionsRemovedOnePermissionLabel',
            ...permissionsI18n);
      case 2:
        return this.i18n(
            this.safetyHubAbusiveNotificationRevocationEnabled_ ?
                'safetyHubUnusedSitePermissionsRemovedTwoPermissionsLabel' :
                'safetyCheckUnusedSitePermissionsRemovedTwoPermissionsLabel',
            ...permissionsI18n);
      case 3:
        return this.i18n(
            this.safetyHubAbusiveNotificationRevocationEnabled_ ?
                'safetyHubUnusedSitePermissionsRemovedThreePermissionsLabel' :
                'safetyCheckUnusedSitePermissionsRemovedThreePermissionsLabel',
            ...permissionsI18n);
      default:
        return this.i18n(
            this.safetyHubAbusiveNotificationRevocationEnabled_ ?
                'safetyHubUnusedSitePermissionsRemovedFourOrMorePermissionsLabel' :
                'safetyCheckUnusedSitePermissionsRemovedFourOrMorePermissionsLabel',
            permissionsI18n[0], permissionsI18n[1], permissionsI18n.length - 2);
    }
  }

  /** Clears all the changes made by a previous action. */
  private resetValues(event: Event) {
    event.stopPropagation();
    this.$.undoToast.hide();
  }

  private onAllowAgainClick_(event: CustomEvent<UnusedSitePermissions>) {
    this.resetValues(event);

    // Set values needed for the action.
    const item = event.detail;
    this.lastUserAction_ = Action.ALLOW_AGAIN;
    this.lastUnusedSitePermissionsAllowedAgain_ = item;

    // Update the toastText_ that isused both as an undo toast text and as a
    // header text.
    this.toastText_ =
        this.i18n('safetyCheckUnusedSitePermissionsToastLabel', item.origin);
    // Only show Undo toast if there are multiple sites to review. Otherwise,
    // once the single site is reviewed, the completion state with a permanent
    // Undo button in the header will be shown.
    if (this.sites_!.length > 1) {
      this.$.undoToast.show();
    }

    this.$.module.animateHide(
        item.origin,
        this.browserProxy_.allowPermissionsAgainForUnusedSite.bind(
            this.browserProxy_, item.origin));

    this.browserProxy_.recordSafetyHubInteraction();
    this.metricsBrowserProxy_
        .recordSafetyHubUnusedSitePermissionsModuleInteractionsHistogram(
            SafetyCheckUnusedSitePermissionsModuleInteractions.ALLOW_AGAIN);

    if (this.doesSiteListIncludeAbusiveNotifications([item])) {
      this.metricsBrowserProxy_
          .recordSafetyHubAbusiveNotificationPermissionRevocationInteractionsHistogram(
              SafetyCheckUnusedSitePermissionsModuleInteractions.ALLOW_AGAIN);
    }
  }

  private async onGotItClick_(e: Event) {
    this.resetValues(e);

    // Set values needed for the action.
    assert(this.sites_ !== null);
    this.lastUserAction_ = Action.GOT_IT;
    this.lastUnusedSitePermissionsListAcknowledged_ = this.sites_;

    // Update the toastText_ that is also used as a header text.
    this.toastText_ = await PluralStringProxyImpl.getInstance().getPluralString(
        'safetyCheckUnusedSitePermissionsToastBulkLabel', this.sites_.length);

    this.$.module.animateHide(
        /* all origins */ null,
        this.browserProxy_.acknowledgeRevokedUnusedSitePermissionsList.bind(
            this.browserProxy_));

    this.browserProxy_.recordSafetyHubInteraction();
    this.metricsBrowserProxy_
        .recordSafetyHubUnusedSitePermissionsModuleInteractionsHistogram(
            SafetyCheckUnusedSitePermissionsModuleInteractions.ACKNOWLEDGE_ALL);

    if (this.doesSiteListIncludeAbusiveNotifications(this.sites_)) {
      this.metricsBrowserProxy_
          .recordSafetyHubAbusiveNotificationPermissionRevocationInteractionsHistogram(
              SafetyCheckUnusedSitePermissionsModuleInteractions
                  .ACKNOWLEDGE_ALL);
    }
  }

  private onMoreActionClick_(e: Event) {
    e.stopPropagation();
    this.$.headerActionMenu.showAt(e.target as HTMLElement);
  }

  private onGoToSettingsClick_(e: Event) {
    e.stopPropagation();
    this.$.headerActionMenu.close();
    Router.getInstance().navigateTo(
        routes.SITE_SETTINGS, /* dynamicParams= */ undefined,
        /* removeSearch= */ true);

    this.metricsBrowserProxy_
        .recordSafetyHubUnusedSitePermissionsModuleInteractionsHistogram(
            SafetyCheckUnusedSitePermissionsModuleInteractions.GO_TO_SETTINGS);

    if (this.sites_ && this.sites_.length > 0 &&
        this.doesSiteListIncludeAbusiveNotifications(this.sites_)) {
      this.metricsBrowserProxy_
          .recordSafetyHubAbusiveNotificationPermissionRevocationInteractionsHistogram(
              SafetyCheckUnusedSitePermissionsModuleInteractions
                  .GO_TO_SETTINGS);
    }
  }

  /* Repopulate the list when unused site permission list is updated. */
  private onUnusedSitePermissionListChanged_(sites: UnusedSitePermissions[]) {
    this.sites_ = sites.map(
        (site: UnusedSitePermissions): UnusedSitePermissionsDisplay =>
            ({...site, detail: this.getPermissionsText_(site.permissions)}));
  }

  private setHeaderToCompletionState_() {
    assert(this.headerString_);
    this.headerString_ = this.toastText_!;
    this.subheaderString_ = '';
    this.headerIconString_ = 'cr:check';
  }

  private async onSitesChanged_() {
    if (this.sites_ === null) {
      return;
    }

    // Run the show animation on all new items, i.e. those items
    // in |this.sites_| which aren't already rendered.
    this.$.module.animateShow(
        this.sites_.map(site => site.origin)
            .filter(origin => !this.renderedOrigins_.includes(origin)));
    this.renderedOrigins_ = this.sites_.map(site => site.origin);

    if (this.shouldShowCompletionInfo_) {
      this.setHeaderToCompletionState_();
      return;
    }

    this.headerString_ =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'safetyCheckUnusedSitePermissionsPrimaryLabel', this.sites_.length);
    this.subheaderString_ =
        await PluralStringProxyImpl.getInstance().getPluralString(
            this.safetyHubAbusiveNotificationRevocationEnabled_ ?
                'safetyHubRevokedPermissionsSecondaryLabel' :
                'safetyCheckUnusedSitePermissionsSecondaryLabel',
            this.sites_.length);
    this.headerIconString_ = 'privacy:page-info';
  }

  private onUndoClick_(e: Event) {
    e.stopPropagation();
    this.undoLastAction_();
  }

  /**
   * Show info that review is completed when there are no permissions left.
   */
  private computeShouldShowCompletionInfo_(): boolean {
    return this.sites_ !== null && this.sites_.length === 0;
  }

  private undoLastAction_() {
    switch (this.lastUserAction_) {
      case Action.ALLOW_AGAIN:
        assert(this.lastUnusedSitePermissionsAllowedAgain_ !== null);
        this.browserProxy_.undoAllowPermissionsAgainForUnusedSite(
            this.lastUnusedSitePermissionsAllowedAgain_);
        if (this.doesSiteListIncludeAbusiveNotifications(
                [this.lastUnusedSitePermissionsAllowedAgain_])) {
          this.metricsBrowserProxy_
              .recordSafetyHubAbusiveNotificationPermissionRevocationInteractionsHistogram(
                  SafetyCheckUnusedSitePermissionsModuleInteractions
                      .UNDO_ALLOW_AGAIN);
        }
        this.lastUnusedSitePermissionsAllowedAgain_ = null;
        this.metricsBrowserProxy_
            .recordSafetyHubUnusedSitePermissionsModuleInteractionsHistogram(
                SafetyCheckUnusedSitePermissionsModuleInteractions
                    .UNDO_ALLOW_AGAIN);
        break;
      case Action.GOT_IT:
        assert(this.lastUnusedSitePermissionsListAcknowledged_ !== null);
        this.browserProxy_.undoAcknowledgeRevokedUnusedSitePermissionsList(
            this.lastUnusedSitePermissionsListAcknowledged_);
        if (this.doesSiteListIncludeAbusiveNotifications(
                this.lastUnusedSitePermissionsListAcknowledged_)) {
          this.metricsBrowserProxy_
              .recordSafetyHubAbusiveNotificationPermissionRevocationInteractionsHistogram(
                  SafetyCheckUnusedSitePermissionsModuleInteractions
                      .UNDO_ACKNOWLEDGE_ALL);
        }
        this.lastUnusedSitePermissionsListAcknowledged_ = null;
        this.metricsBrowserProxy_
            .recordSafetyHubUnusedSitePermissionsModuleInteractionsHistogram(
                SafetyCheckUnusedSitePermissionsModuleInteractions
                    .UNDO_ACKNOWLEDGE_ALL);
        break;
      default:
        assertNotReached();
    }
    this.lastUserAction_ = null;
    this.$.undoToast.hide();
  }

  private onKeyDown_(e: KeyboardEvent) {
    // Only allow undoing via ctrl+z when the undo toast is opened.
    if (!this.$.undoToast.open) {
      return;
    }

    if (isUndoKeyboardEvent(e)) {
      this.undoLastAction_();
      e.stopPropagation();
    }
  }

  // TODO(crbug.com/40267370): Move common functionality between
  // unused_site_permissions_module.ts and notification_permissions_module.ts to
  // a util class.
  private showUndoTooltip_(e: Event) {
    e.stopPropagation();
    const tooltip = this.shadowRoot!.querySelector('cr-tooltip');
    assert(tooltip);
    this.showTooltipAtTarget(tooltip, e.target! as Element);
  }

  private doesSiteListIncludeAbusiveNotifications(sites:
                                                      UnusedSitePermissions[]) {
    // Convert the permission type lists to i18n versions and check if each list
    // includes notifications.
    const listOfPermissionTypes = sites!.map(site => site.permissions);
    const listPermissionsIncludeNotifications = listOfPermissionTypes.map(
        permissions =>
            permissions
                .map(permission => {
                  const localizationString =
                      getLocalizationStringForContentType(permission);
                  return localizationString ? this.i18n(localizationString) :
                                              '';
                })
                .includes('notifications'));

    // Return true if any of the permission type lists includes notifications.
    return listPermissionsIncludeNotifications.includes(true);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-safety-hub-unused-site-permissions':
        SettingsSafetyHubUnusedSitePermissionsModuleElement;
  }
}

customElements.define(
    SettingsSafetyHubUnusedSitePermissionsModuleElement.is,
    SettingsSafetyHubUnusedSitePermissionsModuleElement);