chromium/chrome/browser/resources/settings/privacy_sandbox/privacy_sandbox_topics_subpage.ts

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import '/shared/settings/prefs/prefs.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_collapse/cr_collapse.js';
import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/cr_elements/cr_expand_button/cr_expand_button.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import '../controls/settings_toggle_button.js';
import './privacy_sandbox_interest_item.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {focusWithoutInk} from 'chrome://resources/js/focus_without_ink.js';
import {afterNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js';
import type {FocusConfig} from '../focus_config.js';
import {HatsBrowserProxyImpl, TrustSafetyInteraction} from '../hats_browser_proxy.js';
import {loadTimeData} from '../i18n_setup.js';
import type {MetricsBrowserProxy} from '../metrics_browser_proxy.js';
import {MetricsBrowserProxyImpl} from '../metrics_browser_proxy.js';
import {routes} from '../route.js';
import type {Route} from '../router.js';
import {RouteObserverMixin, Router} from '../router.js';
import type {SettingsSimpleConfirmationDialogElement} from '../simple_confirmation_dialog.js';

import type {CanonicalTopic, PrivacySandboxBrowserProxy, PrivacySandboxInterest, TopicsState} from './privacy_sandbox_browser_proxy.js';
import {PrivacySandboxBrowserProxyImpl} from './privacy_sandbox_browser_proxy.js';
import {getTemplate} from './privacy_sandbox_topics_subpage.html.js';

export interface SettingsPrivacySandboxTopicsSubpageElement {
  $: {
    topicsToggle: SettingsToggleButtonElement,
    footer: HTMLElement,
    footerV2: HTMLElement,
  };
}

const SettingsPrivacySandboxTopicsSubpageElementBase =
    RouteObserverMixin(I18nMixin(PrefsMixin(PolymerElement)));

export class SettingsPrivacySandboxTopicsSubpageElement extends
    SettingsPrivacySandboxTopicsSubpageElementBase {
  static get is() {
    return 'settings-privacy-sandbox-topics-subpage';
  }

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

  static get properties() {
    return {
      /**
       * Preferences state.
       */
      prefs: {
        type: Object,
        notify: true,
      },

      topicsList_: {
        type: Array,
        value() {
          return [];
        },
      },

      blockedTopicsList_: {
        type: Array,
        value() {
          return [];
        },
      },

      /**
       * Used to determine that the Topics list was already fetched and to
       * display the current topics description only after the list is loaded,
       * to avoid displaying first the description for an empty list since the
       * array is empty at first when the page is loaded and switching to the
       * default description once the list is fetched.
       */
      isTopicsListLoaded_: {
        type: Boolean,
        value: false,
      },

      isLearnMoreDialogOpen_: {
        type: Boolean,
        value: false,
      },

      blockedTopicsExpanded_: {
        type: Boolean,
        value: false,
        observer: 'onBlockedTopicsExpanded_',
      },

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

      // Version 2 of Ad Topics Page should be displayed when Proactive Topics
      // Blocking is enabled.
      shouldShowV2_: {
        type: Boolean,
        value: () =>
            loadTimeData.getBoolean('isProactiveTopicsBlockingEnabled'),
      },

      blockTopicDialogTitle_: {
        type: String,
        value: '',
      },

      blockTopicDialogBody_: {
        type: String,
        value: '',
      },

      shouldShowBlockTopicDialog_: {
        type: Boolean,
        value: false,
      },

      shouldShowV2EmptyState_: {
        type: Boolean,
        computed: 'computeShouldShowV2EmptyState_(' +
            'shouldShowV2, prefs.privacy_sandbox.m1.topics_enabled.value)',
      },
    };
  }

  private privacySandboxBrowserProxy_: PrivacySandboxBrowserProxy =
      PrivacySandboxBrowserProxyImpl.getInstance();
  private metricsBrowserProxy_: MetricsBrowserProxy =
      MetricsBrowserProxyImpl.getInstance();
  private topicsList_: PrivacySandboxInterest[];
  private blockedTopicsList_: PrivacySandboxInterest[];
  private currentChildTopics_: CanonicalTopic[];
  private currentInterest_?: PrivacySandboxInterest;
  private focusConfig: FocusConfig;

  private isTopicsListLoaded_: boolean;
  private shouldShowV2_: boolean;
  private shouldShowV2EmptyState_: boolean;
  private blockedTopicsExpanded_: boolean;

  private isLearnMoreDialogOpen_: boolean;
  private shouldShowBlockTopicDialog_: boolean;
  private blockTopicDialogTitle_: string;
  private blockTopicDialogBody_: string;

  override ready() {
    super.ready();

    this.privacySandboxBrowserProxy_.getTopicsState().then(
        state => this.onTopicsStateChanged_(state));

    this.$.footer.querySelectorAll('a').forEach(
        link =>
            link.setAttribute('aria-description', this.i18n('opensInNewTab')));
    this.$.footerV2.querySelectorAll('a').forEach(
        link =>
            link.setAttribute('aria-description', this.i18n('opensInNewTab')));
  }

  // Goal is to not show anything but the toggle and disclaimer when we
  // should show V2 and the pref is false.
  private computeShouldShowV2EmptyState_(): boolean {
    return (
        this.shouldShowV2_ &&
        !this.getPref('privacy_sandbox.m1.topics_enabled').value);
  }

  override currentRouteChanged(newRoute: Route) {
    if (newRoute === routes.PRIVACY_SANDBOX_TOPICS) {
      HatsBrowserProxyImpl.getInstance().trustSafetyInteractionOccurred(
          TrustSafetyInteraction.OPENED_TOPICS_SUBPAGE);
      // Updating the TopicsState because it can be changed by being
      // blocked/unblocked in the Manage Topics Page. Need to keep the data
      // between the two pages up to date.
      if (this.shouldShowV2_) {
        this.privacySandboxBrowserProxy_.getTopicsState().then(
            state => this.onTopicsStateChanged_(state));
      }
    }
  }

  private isTopicsPrefManaged_(): boolean {
    const topicsEnabledPref = this.getPref('privacy_sandbox.m1.topics_enabled');
    if (topicsEnabledPref.enforcement ===
        chrome.settingsPrivate.Enforcement.ENFORCED) {
      assert(!topicsEnabledPref.value);
      return true;
    }
    return false;
  }

  private onTopicsStateChanged_(state: TopicsState) {
    this.topicsList_ = state.topTopics.map(topic => {
      return {topic, removed: false};
    });
    this.blockedTopicsList_ = state.blockedTopics.map(topic => {
      return {topic, removed: true};
    });
    this.isTopicsListLoaded_ = true;
  }

  private isTopicsEnabledAndLoaded_(): boolean {
    return this.getPref('privacy_sandbox.m1.topics_enabled').value &&
        this.isTopicsListLoaded_;
  }

  private isTopicsListEmpty_(): boolean {
    return this.topicsList_.length === 0 && !this.shouldShowV2_;
  }

  private isTopicsListEmptyV2_(): boolean {
    return this.topicsList_.length === 0 && this.shouldShowV2_;
  }

  private isBlockedTopicsListEmptyV2_(): boolean {
    return this.blockedTopicsList_.length === 0 && this.shouldShowV2_;
  }

  private computeBlockedTopicsDescription_(): string {
    return this.i18n(
        this.blockedTopicsList_.length === 0 ?
            'topicsPageBlockedTopicsDescriptionEmpty' :
            'topicsPageBlockedTopicsDescription');
  }

  private getBlockedTopicsDescriptionClass_(): string {
    const defaultClass = 'cr-row continuation cr-secondary-text';
    return this.blockedTopicsList_.length === 0 ?
        `${defaultClass} no-blocked-topics` :
        defaultClass;
  }

  private onToggleChange_(e: Event) {
    const target = e.target as SettingsToggleButtonElement;
    this.metricsBrowserProxy_.recordAction(
        target.checked ? 'Settings.PrivacySandbox.Topics.Enabled' :
                         'Settings.PrivacySandbox.Topics.Disabled');

    this.privacySandboxBrowserProxy_.topicsToggleChanged(target.checked);

    // Reset the list after the toggle changed. From disabled -> enabled, the
    // list should already be empty. From enabled -> disabled, the current list
    // is cleared.
    this.topicsList_ = [];
  }

  private onLearnMoreClick_() {
    this.metricsBrowserProxy_.recordAction(
        'Settings.PrivacySandbox.Topics.LearnMoreClicked');
    this.isLearnMoreDialogOpen_ = true;
  }

  private onCloseDialog_() {
    this.isLearnMoreDialogOpen_ = false;
    afterNextRender(this, async () => {
      // `learnMoreLink` might be null if the toggle was disabled after the
      // dialog was opened.
      this.shadowRoot!.querySelector<HTMLElement>('#learnMoreLink')?.focus();
    });
  }

  private onBlockTopicDialogClose_() {
    const dialog =
        this.shadowRoot!.querySelector<SettingsSimpleConfirmationDialogElement>(
            'settings-simple-confirmation-dialog');
    assert(dialog);
    assert(this.currentInterest_);
    if (dialog.wasConfirmed()) {
      this.updateTopicsStateForSelectedTopic_(this.currentInterest_!);
    }
    this.blockTopicDialogBody_ = '';
    this.blockTopicDialogTitle_ = '';
    this.currentChildTopics_ = [];
    this.shouldShowBlockTopicDialog_ = false;
    this.currentInterest_ = undefined;
  }

  private updateTopicsStateForSelectedTopic_(currentSelectedInterest:
                                                 PrivacySandboxInterest) {
    this.privacySandboxBrowserProxy_.setTopicAllowed(
        currentSelectedInterest.topic!,
        /*allowed=*/ currentSelectedInterest.removed);
    this.privacySandboxBrowserProxy_.getTopicsState().then(
        state => this.onTopicsStateChanged_(state));

    this.metricsBrowserProxy_.recordAction(
        currentSelectedInterest.removed ?
            'Settings.PrivacySandbox.Topics.TopicAdded' :
            'Settings.PrivacySandbox.Topics.TopicRemoved');

    // After allowing or blocking the last item, the focus is lost after the
    // item is removed. Set the focus to the #blockedTopicsRow element.
    afterNextRender(this, async () => {
      if (!this.shadowRoot!.activeElement) {
        this.shadowRoot!.querySelector<HTMLElement>('#blockedTopicsRow')
            ?.focus();
      }
    });
  }

  // In the V2 of the ad topics page, this function is invoked by
  // onInterestedChanged_ to handle the blocking/allowing and updating state.
  private async onInterestChangedV2_() {
    assert(this.currentInterest_!.topic);
    assert(this.currentInterest_!.topic!.displayString);

    // If topic is being unblocked, show toast and update topic state.
    if (this.currentInterest_!.removed) {
      const toast = this.shadowRoot!.querySelector('cr-toast');
      assert(toast);
      toast.show();
      this.updateTopicsStateForSelectedTopic_(this.currentInterest_!);
      return;
    }

    this.currentChildTopics_ =
        await this.privacySandboxBrowserProxy_.getChildTopicsCurrentlyAssigned(
            this.currentInterest_!.topic!);
    // Check if currently selected topic to block has active child topics
    // if it does, show simple confirmation dialog.
    if (this.currentChildTopics_.length !== 0) {
      this.blockTopicDialogTitle_ = loadTimeData.getStringF(
          'manageTopicsDialogTitle',
          this.currentInterest_!.topic!.displayString);
      this.blockTopicDialogBody_ = loadTimeData.getStringF(
          'manageTopicsDialogBody',
          this.currentInterest_!.topic!.displayString);
      this.shouldShowBlockTopicDialog_ = true;
      return;
    }
    // Currently selected topic doesn't have active child topics.
    // Update topics state.
    this.updateTopicsStateForSelectedTopic_(this.currentInterest_!);
    this.blockedTopicsExpanded_ = true;
  }

  // This function is run anytime the interest item changes. Which means that it
  // runs when a user blocks/allows a topic.
  private onInterestChanged_(e: CustomEvent<PrivacySandboxInterest>) {
    this.currentInterest_ = e.detail;
    assert(!this.currentInterest_.site);
    if (this.shouldShowV2_) {
      this.onInterestChangedV2_();
      return;
    }
    this.updateTopicsStateForSelectedTopic_(this.currentInterest_!);
  }

  private onBlockedTopicsExpanded_() {
    if (this.blockedTopicsExpanded_) {
      this.metricsBrowserProxy_.recordAction(
          'Settings.PrivacySandbox.Topics.BlockedTopicsOpened');
    }
  }

  private onPrivacySandboxManageTopicsClick_() {
    Router.getInstance().navigateTo(routes.PRIVACY_SANDBOX_MANAGE_TOPICS);
  }

  private focusConfigChanged_(_newConfig: FocusConfig, oldConfig: FocusConfig) {
    assert(!oldConfig);
    if (routes.PRIVACY_SANDBOX_MANAGE_TOPICS) {
      this.focusConfig.set(routes.PRIVACY_SANDBOX_MANAGE_TOPICS.path, () => {
        const toFocus = this.shadowRoot!.querySelector<HTMLElement>(
            '#privacySandboxManageTopicsLinkRow');
        assert(toFocus);
        focusWithoutInk(toFocus);
      });
    }
  }

  private onHideToastClick_() {
    const toast = this.shadowRoot!.querySelector('cr-toast');
    assert(toast);
    toast.hide();
  }

  private computeTopicsPageToggleSubLabel_(): string {
    return this.i18n(
        this.shouldShowV2_ ? 'topicsPageToggleSubLabelV2' :
                             'topicsPageToggleSubLabel');
  }

  private computeTopicsPageCurrentTopicsHeading_(): string {
    return this.i18n(
        this.shouldShowV2_ ? 'topicsPageActiveTopicsHeading' :
                             'topicsPageCurrentTopicsHeading');
  }

  private computeTopicsPageCurrentTopicsDescription_(): string {
    return this.i18n(
        this.shouldShowV2_ ? 'topicsPageActiveTopicsDescription' :
                             'topicsPageCurrentTopicsDescription');
  }

  private computeTopicsPageCurrentTopicsDescriptionEmpty_(): string {
    return this.i18n(
        this.shouldShowV2_ ? 'topicsPageCurrentTopicsDescriptionEmptyPTB' :
                             'topicsPageCurrentTopicsDescriptionEmpty');
  }

  private computeTopicsPageBlockedTopicsHeading_(): string {
    return this.i18n(
        this.shouldShowV2_ ? 'topicsPageBlockedTopicsHeadingNew' :
                             'topicsPageBlockedTopicsHeading');
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-privacy-sandbox-topics-subpage':
        SettingsPrivacySandboxTopicsSubpageElement;
  }
}

customElements.define(
    SettingsPrivacySandboxTopicsSubpageElement.is,
    SettingsPrivacySandboxTopicsSubpageElement);