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

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

import {HistoryEmbeddingsUserActions, QUERY_RESULT_MINIMUM_AGE} from 'chrome://resources/cr_components/history/constants.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserServiceImpl} from './browser_service.js';
import type {HistoryEntry, HistoryQuery, QueryResult, QueryState} from './externs.js';
import type {HistoryRouterElement} from './router.js';

// Converts a JS Date object to a human readable string in the format of
// YYYY-MM-DD for the query.
export function convertDateToQueryValue(date: Date) {
  const fullYear = date.getFullYear();
  const month = date.getMonth() + 1; /* Month is 0-indexed. */
  const day = date.getDate();

  function twoDigits(value: number): string {
    return value >= 10 ? `${value}` : `0${value}`;
  }

  return `${fullYear}-${twoDigits(month)}-${twoDigits(day)}`;
}

declare global {
  interface HTMLElementTagNameMap {
    'history-query-manager': HistoryQueryManagerElement;
  }
}

export class HistoryQueryManagerElement extends PolymerElement {
  static get is() {
    return 'history-query-manager';
  }

  static get template() {
    return null;
  }

  static get properties() {
    return {
      queryState: {
        type: Object,
        notify: true,
      },

      queryResult: {
        type: Object,
        notify: true,
      },

      router: Object,
    };
  }

  static get observers() {
    return ['searchTermChanged_(queryState.searchTerm)'];
  }

  queryState: QueryState;
  queryResult: QueryResult;
  router?: HistoryRouterElement;
  private eventTracker_: EventTracker = new EventTracker();
  /**
   * When this is non-null, that means there's a QueryResult that's pending
   * metrics logging since this debouncer timestamp. The debouncing is needed
   * because queries are issued as the user types, and we want to skip logging
   * these trivial queries the user typed through.
   */
  private resultPendingMetricsTimestamp_: number|null = null;

  constructor() {
    super();

    this.queryState = {
      // Whether the most recent query was incremental.
      incremental: false,
      // A query is initiated by page load.
      querying: true,
      searchTerm: '',
    };
  }

  override connectedCallback() {
    super.connectedCallback();
    this.eventTracker_.add(
        document, 'change-query', this.onChangeQuery_.bind(this));
    this.eventTracker_.add(
        document, 'query-history', this.onQueryHistory_.bind(this));
    this.eventTracker_.add(document, 'visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flushDebouncedQueryResultMetric_();
      }
    });
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.flushDebouncedQueryResultMetric_();
    this.eventTracker_.removeAll();
  }

  initialize() {
    this.queryHistory_(false /* incremental */);
  }

  private queryHistory_(incremental: boolean) {
    this.set('queryState.querying', true);
    this.set('queryState.incremental', incremental);

    let afterTimestamp;
    if (loadTimeData.getBoolean('enableHistoryEmbeddings') &&
        this.queryState.after) {
      const afterDate = new Date(this.queryState.after);
      afterDate.setHours(0, 0, 0, 0);
      afterTimestamp = afterDate.getTime();
    }

    const browserService = BrowserServiceImpl.getInstance();
    const promise = incremental ?
        browserService.queryHistoryContinuation() :
        browserService.queryHistory(this.queryState.searchTerm, afterTimestamp);
    // Ignore rejected (cancelled) queries.
    promise.then(result => this.onQueryResult_(result), () => {});
  }

  private onChangeQuery_(e: CustomEvent<{search?: string, after?: string}>) {
    const changes = e.detail;
    let needsUpdate = false;

    if (changes.search !== null &&
        changes.search !== this.queryState.searchTerm) {
      this.set('queryState.searchTerm', changes.search);
      needsUpdate = true;
    }

    if (loadTimeData.getBoolean('enableHistoryEmbeddings') &&
        changes.after !== null && changes.after !== this.queryState.after &&
        (Boolean(changes.after) || Boolean(this.queryState.after))) {
      this.set('queryState.after', changes.after);
      needsUpdate = true;
    }

    if (needsUpdate) {
      this.queryHistory_(false);
      if (this.router) {
        this.router.serializeUrl();
      }
    }
  }

  private onQueryHistory_(e: CustomEvent<boolean>): boolean {
    this.queryHistory_(e.detail);
    return false;
  }

  /**
   * @param results List of results with information about the query.
   */
  private onQueryResult_(results: {info: HistoryQuery, value: HistoryEntry[]}) {
    this.set('queryState.querying', false);
    this.set('queryResult.info', results.info);
    this.set('queryResult.results', results.value);
    this.dispatchEvent(
        new CustomEvent('query-finished', {bubbles: true, composed: true}));
  }

  private searchTermChanged_() {
    this.flushDebouncedQueryResultMetric_();

    // TODO(tsergeant): Ignore incremental searches in this metric.
    if (this.queryState.searchTerm) {
      BrowserServiceImpl.getInstance().recordAction('Search');
      this.resultPendingMetricsTimestamp_ = performance.now();
    }
  }

  /**
   * Flushes any pending query result metric waiting to be logged.
   */
  private flushDebouncedQueryResultMetric_() {
    if (this.resultPendingMetricsTimestamp_ &&
        (performance.now() - this.resultPendingMetricsTimestamp_) >=
            QUERY_RESULT_MINIMUM_AGE) {
      BrowserServiceImpl.getInstance().recordHistogram(
          'History.Embeddings.UserActions',
          HistoryEmbeddingsUserActions.NON_EMPTY_QUERY_HISTORY_SEARCH,
          HistoryEmbeddingsUserActions.END);
    }

    // Clear this regardless if it was recorded or not, because we don't want
    // to "try again" to record the same query.
    this.resultPendingMetricsTimestamp_ = null;
  }
}

customElements.define(
    HistoryQueryManagerElement.is, HistoryQueryManagerElement);