chromium/chrome/browser/resources/history/history_list.ts

// Copyright 2015 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_lazy_render/cr_lazy_render.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import 'chrome://resources/polymer/v3_0/iron-scroll-threshold/iron-scroll-threshold.js';
import './shared_style.css.js';
import './history_item.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import type {CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import type {CrLazyRenderElement} from 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.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} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {getDeepActiveElement} from 'chrome://resources/js/util.js';
import type {IronListElement} from 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import type {IronScrollThresholdElement} from 'chrome://resources/polymer/v3_0/iron-scroll-threshold/iron-scroll-threshold.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserServiceImpl} from './browser_service.js';
import {BROWSING_GAP_TIME} from './constants.js';
import type {HistoryEntry, HistoryQuery, QueryState} from './externs.js';
import type {HistoryItemElement} from './history_item.js';
import {searchResultsTitle} from './history_item.js';
import {getTemplate} from './history_list.html.js';

export interface ActionMenuModel {
  index: number;
  item: HistoryEntry;
  target: HTMLElement;
}

type OpenMenuEvent = CustomEvent<ActionMenuModel>;

type HistoryCheckboxSelectEvent = CustomEvent<{
  index: number,
  shiftKey: boolean,
}>;

export interface HistoryListElement {
  $: {
    'infinite-list': IronListElement,
    'scroll-threshold': IronScrollThresholdElement,
    'dialog': CrLazyRenderElement<CrDialogElement>,
    'no-results': HTMLElement,
    'sharedMenu': CrLazyRenderElement<CrActionMenuElement>,
  };
}

declare global {
  interface HTMLElementEventMap {
    'history-checkbox-select': HistoryCheckboxSelectEvent;
    'open-menu': OpenMenuEvent;
    'remove-bookmark-stars': CustomEvent<string>;
  }
}

const HistoryListElementBase = WebUiListenerMixin(I18nMixin(PolymerElement));

export class HistoryListElement extends HistoryListElementBase {
  static get is() {
    return 'history-list';
  }

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

  static get properties() {
    return {
      // The search term for the current query. Set when the query returns.
      searchedTerm: String,

      resultLoadingDisabled_: Boolean,

      /**
       * Indexes into historyData_ of selected items.
       */
      selectedItems: Object,

      canDeleteHistory_: Boolean,

      // An array of history entries in reverse chronological order.
      historyData_: {
        type: Array,
        observer: 'onHistoryDataChanged_',
      },

      lastFocused_: Object,

      listBlurred_: Boolean,

      lastSelectedIndex: Number,

      pendingDelete: {
        notify: true,
        type: Boolean,
      },

      queryState: Object,

      actionMenuModel_: Object,

      scrollTarget: {
        type: Object,
        observer: 'onScrollTargetChanged_',
      },
      scrollOffset: Number,

      isEmpty: {
        type: Boolean,
        reflectToAttribute: true,
        computed: 'computeIsEmpty_(historyData_.length)',
      },
    };
  }

  private historyData_: HistoryEntry[];
  private canDeleteHistory_: boolean =
      loadTimeData.getBoolean('allowDeletingHistory');
  private actionMenuModel_: ActionMenuModel|null = null;
  private resultLoadingDisabled_: boolean = false;
  isEmpty: boolean;
  searchedTerm: string = '';
  selectedItems: Set<number> = new Set();
  pendingDelete: boolean = false;
  lastSelectedIndex: number;
  queryState: QueryState;
  scrollTarget: HTMLElement = document.documentElement;
  scrollOffset: number = 0;

  override connectedCallback() {
    super.connectedCallback();
    this.setAttribute('aria-roledescription', this.i18n('ariaRoleDescription'));
    this.addWebUiListener('history-deleted', () => this.onHistoryDeleted_());
  }

  override ready() {
    super.ready();

    this.setAttribute('role', 'application');

    this.addEventListener('history-checkbox-select', this.onItemSelected_);
    this.addEventListener('open-menu', this.onOpenMenu_);
    this.addEventListener(
        'remove-bookmark-stars', e => this.onRemoveBookmarkStars_(e));
  }

  private fire_(eventName: string, detail?: any) {
    this.dispatchEvent(
        new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
  }

  /////////////////////////////////////////////////////////////////////////////
  // Public methods:

  /**
   * @param info An object containing information about the query.
   * @param results A list of results.
   */
  historyResult(info: HistoryQuery, results: HistoryEntry[]) {
    this.initializeResults_(info, results);
    this.closeMenu_();

    if (info.term && !this.queryState.incremental) {
      getAnnouncerInstance().announce(
          searchResultsTitle(results.length, info.term));
    }

    this.addNewResults(results, this.queryState.incremental, info.finished);
  }

  /**
   * Adds the newly updated history results into historyData_. Adds new fields
   * for each result.
   * @param historyResults The new history results.
   * @param incremental Whether the result is from loading more history, or a
   *     new search/list reload.
   * @param finished True if there are no more results available and result
   *     loading should be disabled.
   */
  addNewResults(
      historyResults: HistoryEntry[], incremental: boolean, finished: boolean) {
    const results = historyResults.slice();
    this.$['scroll-threshold'].clearTriggers();

    if (!incremental) {
      this.resultLoadingDisabled_ = false;
      if (this.historyData_) {
        this.splice('historyData_', 0, this.historyData_.length);
      }
      this.fire_('unselect-all');
      this.scrollTop = 0;
    }

    if (this.historyData_) {
      // If we have previously received data, push the new items onto the
      // existing array.
      this.push('historyData_', ...results);
    } else {
      // The first time we receive data, use set() to ensure the iron-list is
      // initialized correctly.
      this.set('historyData_', results);
    }

    this.resultLoadingDisabled_ = finished;
  }

  private onHistoryDeleted_() {
    // Do not reload the list when there are items checked.
    if (this.getSelectedItemCount() > 0) {
      return;
    }

    // Reload the list with current search state.
    this.fire_('query-history', false);
  }

  selectOrUnselectAll() {
    if (this.historyData_.length === this.getSelectedItemCount()) {
      this.unselectAllItems();
    } else {
      this.selectAllItems();
    }
  }

  /**
   * Select each item in |historyData|.
   */
  selectAllItems() {
    if (this.historyData_.length === this.getSelectedItemCount()) {
      return;
    }

    this.historyData_.forEach((_item, index) => {
      this.changeSelection_(index, true);
    });
  }

  /**
   * Deselect each item in |selectedItems|.
   */
  unselectAllItems() {
    this.selectedItems.forEach((index) => {
      this.changeSelection_(index, false);
    });

    assert(this.selectedItems.size === 0);
  }

  /** @return {number} */
  getSelectedItemCount() {
    return this.selectedItems.size;
  }

  /**
   * Delete all the currently selected history items. Will prompt the user with
   * a dialog to confirm that the deletion should be performed.
   */
  deleteSelectedWithPrompt() {
    if (!this.canDeleteHistory_) {
      return;
    }

    const browserService = BrowserServiceImpl.getInstance();
    browserService.recordAction('RemoveSelected');
    if (this.queryState.searchTerm !== '') {
      browserService.recordAction('SearchResultRemove');
    }
    this.$.dialog.get().showModal();

    // TODO(dbeam): remove focus flicker caused by showModal() + focus().
    const button =
        this.shadowRoot!.querySelector<HTMLElement>('.action-button');
    assert(button);
    button.focus();
  }

  // Notifies the iron-list of this element being potentially resized.
  notifyResize() {
    this.$['infinite-list'].notifyResize();
  }

  /////////////////////////////////////////////////////////////////////////////
  // Private methods:

  /**
   * Set the selection status for an item at a particular index.
   */
  private changeSelection_(index: number, selected: boolean) {
    this.set(`historyData_.${index}.selected`, selected);
    if (selected) {
      this.selectedItems.add(index);
    } else {
      this.selectedItems.delete(index);
    }
  }

  /**
   * Performs a request to the backend to delete all selected items. If
   * successful, removes them from the view. Does not prompt the user before
   * deleting -- see deleteSelectedWithPrompt for a version of this method which
   * does prompt.
   */
  private deleteSelected_() {
    assert(!this.pendingDelete);

    const toBeRemoved = Array.from(this.selectedItems.values())
                            .map((index) => this.get(`historyData_.${index}`));

    this.deleteItems_(toBeRemoved).then(() => {
      this.pendingDelete = false;
      this.removeItemsByIndex_(Array.from(this.selectedItems));
      this.fire_('unselect-all');
      if (this.historyData_.length === 0) {
        // Try reloading if nothing is rendered.
        this.fire_('query-history', false);
      }
    });
  }

  removeItemsForTest(indices: number[]) {
    this.removeItemsByIndex_(indices);
  }

  /**
   * Remove all |indices| from the history list. Uses notifySplices to send a
   * single large notification to Polymer, rather than many small notifications,
   * which greatly improves performance.
   */
  private removeItemsByIndex_(indices: number[]) {
    const splices: any[] = [];
    indices.sort(function(a, b) {
      // Sort in reverse numerical order.
      return b - a;
    });
    indices.forEach((index) => {
      const item = this.historyData_.splice(index, 1);
      splices.push({
        index: index,
        removed: [item],
        addedCount: 0,
        object: this.historyData_,
        type: 'splice',
      });
    });
    this.notifySplices('historyData_', splices);
  }

  removeItemsByIndexForTesting(indices: number[]) {
    this.removeItemsByIndex_(indices);
  }

  /**
   * Closes the overflow menu.
   */
  private closeMenu_() {
    const menu = this.$.sharedMenu.getIfExists();
    if (menu && menu.open) {
      this.actionMenuModel_ = null;
      menu.close();
    }
  }

  /////////////////////////////////////////////////////////////////////////////
  // Event listeners:

  private onDialogConfirmClick_() {
    BrowserServiceImpl.getInstance().recordAction('ConfirmRemoveSelected');

    this.deleteSelected_();
    const dialog = this.$.dialog.getIfExists();
    assert(dialog);
    dialog.close();
  }

  private onDialogCancelClick_() {
    BrowserServiceImpl.getInstance().recordAction('CancelRemoveSelected');

    const dialog = this.$.dialog.getIfExists();
    assert(dialog);
    dialog.close();
  }

  /**
   * Remove bookmark star for history items with matching URLs.
   */
  private onRemoveBookmarkStars_(e: CustomEvent<string>) {
    const url = e.detail;

    if (this.historyData_ === undefined) {
      return;
    }

    for (let i = 0; i < this.historyData_.length; i++) {
      if (this.historyData_[i].url === url) {
        this.set(`historyData_.${i}.starred`, false);
      }
    }
  }

  /**
   * Called when the page is scrolled to near the bottom of the list.
   */
  private onScrollToBottom_() {
    if (this.resultLoadingDisabled_ || this.queryState.querying) {
      return;
    }

    this.fire_('query-history', true);
  }

  /**
   * Open the overflow menu and ensure that the item is visible in the scroll
   * pane when its menu is opened (it is possible to open off-screen items using
   * keyboard shortcuts).
   */
  private onOpenMenu_(e: OpenMenuEvent) {
    const index = e.detail.index;
    const list = this.$['infinite-list'];
    if (index < list.firstVisibleIndex || index > list.lastVisibleIndex) {
      list.scrollToIndex(index);
    }

    const target = e.detail.target;
    this.actionMenuModel_ = e.detail;
    this.$.sharedMenu.get().showAt(target);
  }

  private onMoreFromSiteClick_() {
    BrowserServiceImpl.getInstance().recordAction('EntryMenuShowMoreFromSite');

    assert(this.$.sharedMenu.getIfExists());
    this.fire_(
        'change-query', {search: 'host:' + this.actionMenuModel_!.item.domain});
    this.actionMenuModel_ = null;
    this.closeMenu_();
  }

  private deleteItems_(items: HistoryEntry[]): Promise<void> {
    const removalList = items.map(item => ({
                                    url: item.url,
                                    timestamps: item.allTimestamps,
                                  }));

    this.pendingDelete = true;
    return BrowserServiceImpl.getInstance().removeVisits(removalList);
  }

  private onRemoveBookmarkClick_() {
    const browserService = BrowserServiceImpl.getInstance();
    browserService.removeBookmark(this.actionMenuModel_!.item.url);
    this.fire_('remove-bookmark-stars', this.actionMenuModel_!.item.url);
    this.closeMenu_();
  }

  private onRemoveFromHistoryClick_() {
    const browserService = BrowserServiceImpl.getInstance();
    browserService.recordAction('EntryMenuRemoveFromHistory');

    assert(!this.pendingDelete);
    assert(this.$.sharedMenu.getIfExists());
    const itemData = this.actionMenuModel_!;

    this.deleteItems_([itemData.item]).then(() => {
      getAnnouncerInstance().announce(
          this.i18n('deleteSuccess', itemData.item.title));

      // This unselect-all resets the toolbar when deleting a selected item
      // and clears selection state which can be invalid if items move
      // around during deletion.
      // TODO(tsergeant): Make this automatic based on observing list
      // modifications.
      this.pendingDelete = false;
      this.fire_('unselect-all');
      this.removeItemsByIndex_([itemData.index]);

      const index = itemData.index;
      if (index === undefined) {
        return;
      }

      if (this.historyData_.length > 0) {
        setTimeout(() => {
          this.$['infinite-list'].focusItem(
              Math.min(this.historyData_.length - 1, index));
          const item = getDeepActiveElement() as HistoryItemElement;
          if (item && item.focusOnMenuButton) {
            item.focusOnMenuButton();
          }
        }, 1);
      }
    });
    this.closeMenu_();
  }

  private onItemSelected_(e: HistoryCheckboxSelectEvent) {
    const index = e.detail.index;
    const indices = [];

    // Handle shift selection. Change the selection state of all items between
    // |path| and |lastSelected| to the selection state of |item|.
    if (e.detail.shiftKey && this.lastSelectedIndex !== undefined) {
      for (let i = Math.min(index, this.lastSelectedIndex);
           i <= Math.max(index, this.lastSelectedIndex); i++) {
        indices.push(i);
      }
    }

    if (indices.length === 0) {
      indices.push(index);
    }

    const selected = !this.selectedItems.has(index);

    indices.forEach((index) => {
      this.changeSelection_(index, selected);
    });

    this.lastSelectedIndex = index;
  }

  /////////////////////////////////////////////////////////////////////////////
  // Template helpers:

  /**
   * Check whether the time difference between the given history item and the
   * next one is large enough for a spacer to be required.
   */
  private needsTimeGap_(_item: HistoryEntry, index: number): boolean {
    const length = this.historyData_.length;
    if (index === undefined || index >= length - 1 || length === 0) {
      return false;
    }

    const currentItem = this.historyData_[index];
    const nextItem = this.historyData_[index + 1];

    if (this.searchedTerm) {
      return currentItem.dateShort !== nextItem.dateShort;
    }

    return currentItem.time - nextItem.time > BROWSING_GAP_TIME &&
        currentItem.dateRelativeDay === nextItem.dateRelativeDay;
  }

  /**
   * True if the given item is the beginning of a new card.
   * @param i Index of |item| within |historyData_|.
   */
  private isCardStart_(_item: HistoryEntry, i: number): boolean {
    const length = this.historyData_.length;
    if (i === undefined || length === 0 || i > length - 1) {
      return false;
    }
    return i === 0 ||
        this.historyData_[i].dateRelativeDay !==
        this.historyData_[i - 1].dateRelativeDay;
  }

  /**
   * True if the given item is the end of a card.
   * @param i Index of |item| within |historyData_|.
   */
  private isCardEnd_(_item: HistoryEntry, i: number): boolean {
    const length = this.historyData_.length;
    if (i === undefined || length === 0 || i > length - 1) {
      return false;
    }
    return i === length - 1 ||
        this.historyData_[i].dateRelativeDay !==
        this.historyData_[i + 1].dateRelativeDay;
  }

  private hasResults_(): boolean {
    return this.historyData_.length > 0;
  }

  private noResultsMessage_(searchedTerm: string): string {
    const messageId = searchedTerm !== '' ? 'noSearchResults' : 'noResults';
    return loadTimeData.getString(messageId);
  }

  private canSearchMoreFromSite_(searchedTerm: string, domain: string):
      boolean {
    return searchedTerm === '' || searchedTerm !== domain;
  }

  private initializeResults_(info: HistoryQuery, results: HistoryEntry[]) {
    if (results.length === 0) {
      return;
    }

    let currentDate = results[0].dateRelativeDay;

    for (let i = 0; i < results.length; i++) {
      // Sets the default values for these fields to prevent undefined types.
      results[i].selected = false;
      results[i].readableTimestamp =
          info.term === '' ? results[i].dateTimeOfDay : results[i].dateShort;

      if (results[i].dateRelativeDay !== currentDate) {
        currentDate = results[i].dateRelativeDay;
      }
    }
  }

  /**
   * Adding in order to address an issue with a flaky test. After the list is
   * updated, the test would not see the updated elements when using Polymer 2.
   * This has yet to be reproduced in manual testing.
   */
  private onHistoryDataChanged_() {
    this.$['infinite-list'].fire('iron-resize');
  }

  private getHistoryEmbeddingsMatches_(): HistoryEntry[] {
    return this.historyData_.slice(0, 3);
  }

  private showHistoryEmbeddings_(): boolean {
    return loadTimeData.getBoolean('enableHistoryEmbeddings') &&
        !!this.searchedTerm && this.historyData_?.length > 0;
  }

  private onScrollTargetChanged_() {
    // It is possible (eg, when middle clicking the reload button) for all other
    // resize events to fire before the list is attached and can be measured.
    // Adding another resize here ensures it will get sized correctly.
    this.$['infinite-list'].notifyResize();
  }

  private computeIsEmpty_() {
    return this.historyData_.length === 0;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'history-list': HistoryListElement;
  }
}

customElements.define(HistoryListElement.is, HistoryListElement);