chromium/chrome/browser/resources/search_engine_choice/app.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://search-engine-choice/tangible_sync_style_shared.css.js';
import 'chrome://resources/cr_components/localized_link/localized_link.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_radio_group/cr_radio_group.js';
import 'chrome://resources/cr_elements/cr_radio_button/cr_radio_button.js';
import 'chrome://resources/cr_elements/cr_icons.css.js';
import './strings.m.js';

import type {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {getFaviconForPageURL} from 'chrome://resources/js/icon.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {afterNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './app.html.js';
import type {SearchEngineChoice} from './browser_proxy.js';
import {SearchEngineChoiceBrowserProxy} from './browser_proxy.js';
import {PageHandler_ScrollState} from './search_engine_choice.mojom-webui.js';
import type {PageHandlerRemote} from './search_engine_choice.mojom-webui.js';

export interface SearchEngineChoiceAppElement {
  $: {
    infoDialog: CrDialogElement,
    actionButton: CrButtonElement,
    infoLink: HTMLElement,
    choiceList: HTMLElement,
    buttonContainer: HTMLElement,
  };
}

const SearchEngineChoiceAppElementBase = I18nMixin(PolymerElement);

export class SearchEngineChoiceAppElement extends
    SearchEngineChoiceAppElementBase {
  static get is() {
    return 'search-engine-choice-app';
  }

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

  static get properties() {
    return {
      /**
       * The choice list is passed as JSON because it doesn't change
       * dynamically, so it would be better to have it available as loadtime
       * data.
       */
      choiceList_: {
        type: Array,
        value() {
          return JSON.parse(loadTimeData.getString('choiceList'));
        },
      },

      // The choice will always be > 0 when selected for prepopulated engines
      // and == 0 for a custom search engine.
      selectedChoice_: {
        type: Number,
        value: -1,
        observer: 'onSelectedChoiceChanged_',
      },

      isActionButtonDisabled_: {
        type: Boolean,
        computed: 'computeActionButtonDisabled_(selectedChoice_, ' +
            'hasUserScrolledToTheBottom_)',
      },

      actionButtonText_: {
        type: String,
        computed: 'getActionButtonText_(hasUserScrolledToTheBottom_)',
      },

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

      snippetDisplayed_: Boolean,
    };
  }

  private choiceList_: SearchEngineChoice[];
  private selectedChoice_: string;
  private pageHandler_: PageHandlerRemote;
  private hasUserScrolledToTheBottom_: boolean;
  private actionButtonText_: string;
  private snippetDisplayed_: boolean;
  private resizeObserver_: ResizeObserver|null = null;

  constructor() {
    super();
    this.pageHandler_ = SearchEngineChoiceBrowserProxy.getInstance().handler;
  }

  override connectedCallback() {
    super.connectedCallback();

    // Change the `icon_path` format so that it can be used with the
    // `background-image` property in HTML. The
    // `background-image` property should be used because `getFaviconForPageURL`
    // returns an `image-set` and not a url.
    this.choiceList_.forEach((searchEngine: SearchEngineChoice) => {
      if (searchEngine.prepopulateId === 0) {
        // Fetch the favicon from the Favicon Service for custom search
        // engines.
        searchEngine.iconPath =
            getFaviconForPageURL(searchEngine?.url!, false, '', 24);
      } else {
        searchEngine.iconPath = 'image-set(url(' + searchEngine.iconPath +
            ') 1x, url(' + searchEngine.iconPath + '@2x) 2x)';
      }
    });

    this.addResizeObserver_();

    afterNextRender(this, () => {
      const isPageScrollable =
          document.body.scrollHeight > document.body.clientHeight;

      // If the page doesn't contain a scrollbar then the user is already at the
      // bottom.
      this.hasUserScrolledToTheBottom_ = !isPageScrollable;

      if (isPageScrollable) {
        document.addEventListener('scroll', this.onPageScroll_.bind(this));
      }

      window.addEventListener('resize', this.onPageResize_.bind(this));
      this.pageHandler_.displayDialog();
    });
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.resizeObserver_!.disconnect();
  }

  private addResizeObserver_() {
    function buttonAndListOverlap(
        buttonRect: DOMRect, listRect: DOMRect, offset: number): boolean {
      return !(
          buttonRect.right + offset < listRect.left ||
          buttonRect.left - offset > listRect.right ||
          buttonRect.bottom < listRect.top || buttonRect.top > listRect.bottom);
    }

    this.resizeObserver_ = new ResizeObserver(() => {
      // The button container should hide the remaining elements of the list
      // when they overlap so that the search engines and submit button don't
      // block each other.
      const buttonRect = this.$.actionButton.getBoundingClientRect();
      const listRect = this.$.choiceList.getBoundingClientRect();

      // We add an offset to mitigate the change in position caused by the
      // addition of the scrollbar.
      let offset = 0;
      if (this.$.choiceList.classList.contains('overlap-mitigation')) {
        offset = 30;
      }

      this.$.choiceList.classList.toggle(
          'overlap-mitigation',
          buttonAndListOverlap(buttonRect, listRect, offset));
      this.$.buttonContainer.classList.toggle(
          'overlap-mitigation',
          buttonAndListOverlap(buttonRect, listRect, offset));
    });
    this.resizeObserver_.observe(document.body);
  }

  private onLinkClicked_() {
    this.$.infoDialog.showModal();
    this.pageHandler_.handleLearnMoreLinkClicked();
  }

  private needsUserChoice_() {
    return parseInt(this.selectedChoice_) === -1;
  }

  // The action button will be disabled if the user scrolls to the bottom of
  // the list without making a search engine choice.
  private computeActionButtonDisabled_() {
    return this.hasUserScrolledToTheBottom_ && this.needsUserChoice_();
  }

  private onActionButtonClicked_() {
    if (this.hasUserScrolledToTheBottom_) {
      this.pageHandler_.handleSearchEngineChoiceSelected(
          parseInt(this.selectedChoice_));
      return;
    }

    this.pageHandler_.handleMoreButtonClicked();
    document.addEventListener('scrollend', this.onPageScrollEnd_.bind(this));
    // Force change the "More" button to "Set as default" to prevent users from
    // being blocked on the screen in case of error.
    window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'});
  }

  private handleContentScrollStateUpdate_(forceFullyDisplayed: boolean) {
    if (!forceFullyDisplayed &&
        this.getScrollState_() === PageHandler_ScrollState.kNotAtTheBottom) {
      return;
    }

    this.hasUserScrolledToTheBottom_ = true;
    window.removeEventListener('resize', this.onPageResize_.bind(this));
    window.removeEventListener('scroll', this.onPageScroll_.bind(this));
    window.removeEventListener('scrollend', this.onPageScrollEnd_.bind(this));
  }

  private getMarketingSnippetClass_(item: SearchEngineChoice) {
    return item.showMarketingSnippet ? '' : 'truncate-text';
  }

  private onInfoDialogButtonClicked_() {
    this.$.infoDialog.close();
  }

  private resetSnippetState_(prepopulatedId: number) {
    if (prepopulatedId === -1) {
      return;
    }

    // Get the selected engine.
    const choice =
        this.choiceList_.find(elem => elem.prepopulateId === prepopulatedId)!;
    choice.showMarketingSnippet = false;
    this.snippetDisplayed_ = false;
  }

  private showSearchEngineSnippet_(prepopulateId: number) {
    if (prepopulateId === -1) {
      return;
    }

    // Get the selected engine.
    const choice =
        this.choiceList_.find(elem => elem.prepopulateId === prepopulateId)!;

    choice.showMarketingSnippet = true;
    this.snippetDisplayed_ = true;
  }

  private onSelectedChoiceChanged_(
      newPrepopulatedId: string, oldPrepopulatedId: string) {
    // No search engine selected.
    if (parseInt(newPrepopulatedId) === -1) {
      return;
    }

    chrome.metricsPrivate.recordUserAction('ExpandSearchEngineDescription');
    this.resetSnippetState_(parseInt(oldPrepopulatedId));
    this.showSearchEngineSnippet_(parseInt(newPrepopulatedId));
  }

  private onPageResize_() {
    this.handleContentScrollStateUpdate_(/*forceFullyDisplayed=*/ false);
  }

  private onPageScroll_() {
    this.handleContentScrollStateUpdate_(/*forceFullyDisplayed=*/ false);
  }

  private getScrollState_() {
    const scrollDifference =
        document.body.scrollHeight - window.innerHeight - window.scrollY;
    if (scrollDifference <= 0) {
      return PageHandler_ScrollState.kAtTheBottom;
    }
    return scrollDifference <= 1 ?
        PageHandler_ScrollState.kAtTheBottomWithErrorMargin :
        PageHandler_ScrollState.kNotAtTheBottom;
  }

  // This function is called only when the "More" button is clicked.
  private onPageScrollEnd_() {
    this.pageHandler_.recordScrollState(this.getScrollState_());
    this.handleContentScrollStateUpdate_(/*forceFullyDisplayed=*/ true);
  }

  private getActionButtonText_() {
    return this.i18n(
        this.hasUserScrolledToTheBottom_ ? 'submitButtonText' :
                                           'moreButtonText');
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'search-engine-choice-app': SearchEngineChoiceAppElement;
  }
}

customElements.define(
    SearchEngineChoiceAppElement.is, SearchEngineChoiceAppElement);