chromium/chrome/browser/resources/settings/safety_hub/notification_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.

/**
 * @fileoverview
 * 'settings-safety-hub-notification-permissions-module' is the module in Safety
 * Hub page that show the origins sending a lot of notifications.
 */

import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import '../settings_shared.css.js';
import '../i18n_setup.js';
import '../icons.html.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 {BaseMixin} from '../base_mixin.js';
import type {MetricsBrowserProxy} from '../metrics_browser_proxy.js';
import {MetricsBrowserProxyImpl, SafetyCheckNotificationsModuleInteractions} from '../metrics_browser_proxy.js';
import {routes} from '../route.js';
import type {Route} from '../router.js';
import {RouteObserverMixin, Router} from '../router.js';
import type {NotificationPermission, SafetyHubBrowserProxy} from '../safety_hub/safety_hub_browser_proxy.js';
import {SafetyHubBrowserProxyImpl, SafetyHubEvent} from '../safety_hub/safety_hub_browser_proxy.js';
import {SiteSettingsMixin} from '../site_settings/site_settings_mixin.js';
import {TooltipMixin} from '../tooltip_mixin.js';

import {getTemplate} from './notification_permissions_module.html.js';
import type {SettingsSafetyHubModuleElement, SiteInfo, SiteInfoWithTarget} from './safety_hub_module.js';

export interface SettingsSafetyHubNotificationPermissionsModuleElement {
  $: {
    actionMenu: CrActionMenuElement,
    blockAllButton: HTMLElement,
    bulkUndoButton: HTMLElement,
    headerActionMenu: CrActionMenuElement,
    ignore: HTMLElement,
    module: SettingsSafetyHubModuleElement,
    reset: HTMLElement,
    toastUndoButton: HTMLElement,
    undoNotification: HTMLElement,
    undoToast: CrToastElement,
  };
}

/**
 * The list of actions that a user can take with regards to the permissions of
 * notifications.
 */
enum Actions {
  BLOCK = 'block',
  BLOCK_ALL = 'block_all',
  IGNORE = 'ignore',
  RESET = 'reset',
}

/* Information about notification permissions. */
interface NotificationPermissionsDisplay extends NotificationPermission,
                                                 SiteInfo {}

const SettingsSafetyHubNotificationPermissionsModuleElementBase =
    TooltipMixin(WebUiListenerMixin(RouteObserverMixin(
        BaseMixin(SiteSettingsMixin(I18nMixin(PolymerElement))))));

export class SettingsSafetyHubNotificationPermissionsModuleElement extends
    SettingsSafetyHubNotificationPermissionsModuleElementBase {
  static get is() {
    return 'settings-safety-hub-notification-permissions-module';
  }

  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,

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

      // The last action taken by the user: block, reset or ignore.
      lastUserAction_: String,

      // The last origins that the user interacted with.
      lastOrigins_: Array,

      // List of domains that sends a lot of notifications.
      sites_: {
        type: Array,
        value: null,
      },

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

  static get observers() {
    return [
      'updateUndoNotificationText_(lastUserAction_, lastOrigins_)',
      'onSitesChanged_(sites_, shouldShowCompletionInfo_)',
    ];
  }

  private headerString_: string;
  private subheaderString_: string;
  private headerIconString_: string;
  private toastText_: string|null;
  private sites_: NotificationPermissionsDisplay[]|null;
  private shouldShowCompletionInfo_: boolean;
  private lastOrigins_: string[] = [];
  private renderedOrigins_: string[] = [];
  private lastUserAction_: Actions|null;
  private eventTracker_: EventTracker = new EventTracker();
  private browserProxy_: SafetyHubBrowserProxy =
      SafetyHubBrowserProxyImpl.getInstance();
  private metricsBrowserProxy_: MetricsBrowserProxy =
      MetricsBrowserProxyImpl.getInstance();

  override async connectedCallback() {
    // Register for review notification permission list updates.
    this.addWebUiListener(
        SafetyHubEvent.NOTIFICATION_PERMISSIONS_MAYBE_CHANGED,
        (sites: NotificationPermission[]) =>
            this.onNotificationPermissionListChanged_(sites));

    const sites = await this.browserProxy_.getNotificationPermissionReview();
    this.onNotificationPermissionListChanged_(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_
          .recordSafetyHubNotificationPermissionsModuleListCountHistogram(
              this.sites_.length);
    }

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

  /* Repopulate the list when notification permission list is updated. */
  private onNotificationPermissionListChanged_(sites:
                                                   NotificationPermission[]) {
    this.sites_ = sites.map(
        (site: NotificationPermission): NotificationPermissionsDisplay =>
            ({...site, detail: site.notificationInfoString}));
  }

  private async setHeaderToCompletionState_() {
    assert(this.toastText_);
    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(
            'safetyHubNotificationPermissionsPrimaryLabel', this.sites_.length);
    this.subheaderString_ =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'safetyHubNotificationPermissionsSecondaryLabel',
            this.sites_.length);
    this.headerIconString_ = 'settings:notifications-none';
  }

  /** Clears all the changes made by a previous action. */
  private resetValues_(e: Event) {
    e.stopPropagation();
    this.$.undoToast.hide();
    this.lastOrigins_ = [];
    this.lastUserAction_ = null;
  }

  /** Sets all the values needed for an action. */
  private setValues_(origins: string[], action: Actions) {
    //  Both lastUserAction_ and lastOrigins_ need to be reset before setting
    //  new values to prevent triggering updateUndoNotificationText_ twice
    //  (which can cause issues like "flickering" on the header string, e.g.
    //  when the wrong header string is shown for a split second before the
    //  correct one).
    assert(!this.lastUserAction_);
    assert(!this.lastOrigins_.length);
    this.lastOrigins_ = origins;
    this.lastUserAction_ = action;
  }

  private onBlockClick_(e: CustomEvent<NotificationPermission>) {
    this.resetValues_(e);
    this.setValues_([e.detail.origin], Actions.BLOCK);
    this.showUndoToast_();

    this.$.module.animateHide(
        e.detail.origin,
        this.browserProxy_.blockNotificationPermissionForOrigins.bind(
            this.browserProxy_, this.lastOrigins_));

    this.browserProxy_.recordSafetyHubInteraction();
    this.metricsBrowserProxy_
        .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
            SafetyCheckNotificationsModuleInteractions.BLOCK);
  }

  private onMoreActionClick_(e: CustomEvent<SiteInfoWithTarget>) {
    this.resetValues_(e);
    this.lastOrigins_ = [e.detail.origin];
    this.$.actionMenu.showAt(e.detail.target as HTMLElement);
  }

  private onIgnoreClick_(e: Event) {
    const tempLastOrigins = this.lastOrigins_;
    this.resetValues_(e);
    this.setValues_(tempLastOrigins, Actions.IGNORE);
    this.showUndoToast_();
    this.$.actionMenu.close();

    // |lastOrigins| is set to a 1-item array containing the item on which
    // the context menu with the |reset| option was open,
    // in |onMoreActionClick_|.
    this.$.module.animateHide(
        this.lastOrigins_[0],
        this.browserProxy_.ignoreNotificationPermissionForOrigins.bind(
            this.browserProxy_, this.lastOrigins_));

    this.browserProxy_.recordSafetyHubInteraction();
    this.metricsBrowserProxy_
        .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
            SafetyCheckNotificationsModuleInteractions.IGNORE);
  }

  private onResetClick_(e: Event) {
    const tempLastOrigins = this.lastOrigins_;
    this.resetValues_(e);
    this.setValues_(tempLastOrigins, Actions.RESET);
    this.showUndoToast_();
    this.$.actionMenu.close();

    // |lastOrigins| is set to a 1-item array containing the item on which
    // the context menu with the |reset| option was open,
    // in |onMoreActionClick_|.
    this.$.module.animateHide(
        this.lastOrigins_[0],
        this.browserProxy_.resetNotificationPermissionForOrigins.bind(
            this.browserProxy_, this.lastOrigins_));

    this.browserProxy_.recordSafetyHubInteraction();
    this.metricsBrowserProxy_
        .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
            SafetyCheckNotificationsModuleInteractions.RESET);
  }

  private onBlockAllClick_(e: Event) {
    this.resetValues_(e);
    // To be able to undo the block-all action, we need to keep track of all
    // origins that were blocked.
    assert(this.sites_);
    this.setValues_(this.sites_.map(site => site.origin), Actions.BLOCK_ALL);

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

    this.browserProxy_.recordSafetyHubInteraction();
    this.metricsBrowserProxy_
        .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
            SafetyCheckNotificationsModuleInteractions.BLOCK_ALL);
  }

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

  private onHeaderMoreActionClick_(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_NOTIFICATIONS, /* dynamicParams= */ undefined,
        /* removeSearch= */ true);

    this.metricsBrowserProxy_
        .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
            SafetyCheckNotificationsModuleInteractions.GO_TO_SETTINGS);
  }

  private async updateUndoNotificationText_() {
    if (!this.lastUserAction_ || this.lastOrigins_.length === 0) {
      return;
    }
    switch (this.lastUserAction_) {
      case Actions.BLOCK:
        this.toastText_ = this.i18n(
            'safetyCheckNotificationPermissionReviewBlockedToastLabel',
            this.lastOrigins_[0]);
        break;
      case Actions.BLOCK_ALL:
        this.toastText_ =
            await PluralStringProxyImpl.getInstance().getPluralString(
                'safetyCheckNotificationPermissionReviewBlockAllToastLabel',
                this.lastOrigins_.length);
        break;
      case Actions.IGNORE:
        this.toastText_ = this.i18n(
            'safetyCheckNotificationPermissionReviewIgnoredToastLabel',
            this.lastOrigins_[0]);
        break;
      case Actions.RESET:
        this.toastText_ = this.i18n(
            'safetyCheckNotificationPermissionReviewResetToastLabel',
            this.lastOrigins_[0]);
        break;
      default:
        assertNotReached();
    }
  }

  private showUndoToast_() {
    // 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();
    }
  }

  private undoLastAction_() {
    switch (this.lastUserAction_) {
      // As BLOCK and RESET actions just change the notification permission,
      // undoing them only requires allowing notification permissions again.
      case Actions.BLOCK:
        this.browserProxy_.allowNotificationPermissionForOrigins(
            this.lastOrigins_);
        this.metricsBrowserProxy_
            .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
                SafetyCheckNotificationsModuleInteractions.UNDO_BLOCK);
        break;
      case Actions.BLOCK_ALL:
        this.browserProxy_.allowNotificationPermissionForOrigins(
            this.lastOrigins_);
        this.metricsBrowserProxy_
            .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
                SafetyCheckNotificationsModuleInteractions.UNDO_BLOCK_ALL);
        break;
      case Actions.RESET:
        this.browserProxy_.allowNotificationPermissionForOrigins(
            this.lastOrigins_);
        this.metricsBrowserProxy_
            .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
                SafetyCheckNotificationsModuleInteractions.UNDO_RESET);
        break;
      case Actions.IGNORE:
        this.browserProxy_.undoIgnoreNotificationPermissionForOrigins(
            this.lastOrigins_);
        this.metricsBrowserProxy_
            .recordSafetyHubNotificationPermissionsModuleInteractionsHistogram(
                SafetyCheckNotificationsModuleInteractions.UNDO_IGNORE);
        break;
      default:
        assertNotReached();
    }
    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();
    }
  }

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

  private getIgnoreAriaLabelForOrigins(origins: string[]): string {
    // A label is only needed when the action menu is shown for a single origin.
    if (origins.length !== 1) {
      return '';
    }
    return this.i18n(
        'safetyCheckNotificationPermissionReviewIgnoreAriaLabel', origins[0]);
  }

  private getResetAriaLabelForOrigins(origins: string[]): string {
    // A label is only needed when the action menu is shown for a single origin.
    if (origins.length !== 1) {
      return '';
    }
    return this.i18n(
        'safetyCheckNotificationPermissionReviewResetAriaLabel', origins[0]);
  }

  private showUndoTooltip_(e: Event) {
    e.stopPropagation();
    const tooltip = this.shadowRoot!.querySelector('cr-tooltip');
    assert(tooltip);
    this.showTooltipAtTarget(tooltip, e.target! as Element);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-safety-hub-notification-permissions-module':
        SettingsSafetyHubNotificationPermissionsModuleElement;
  }
}

customElements.define(
    SettingsSafetyHubNotificationPermissionsModuleElement.is,
    SettingsSafetyHubNotificationPermissionsModuleElement);