chromium/ui/webui/resources/cr_components/history_embeddings/history_embeddings.ts

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

import '//resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import '//resources/cr_elements/cr_action_menu/cr_action_menu.js';
import '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import '//resources/cr_elements/cr_hidden_style.css.js';
import '//resources/cr_elements/cr_icon/cr_icon.js';
import '//resources/cr_elements/cr_loading_gradient/cr_loading_gradient.js';
import '//resources/cr_elements/cr_shared_vars.css.js';
import '//resources/cr_elements/cr_url_list_item/cr_url_list_item.js';
import './icons.html.js';

import {HistoryResultType, QUERY_RESULT_MINIMUM_AGE} from '//resources/cr_components/history/constants.js';
import type {CrActionMenuElement} from '//resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrFeedbackButtonsElement} from '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import {CrFeedbackOption} from '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import type {CrLazyRenderElement} from '//resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import {I18nMixin} from '//resources/cr_elements/i18n_mixin.js';
import {assert} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import type {Time} from '//resources/mojo/mojo/public/mojom/base/time.mojom-webui.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import type {DomRepeatEvent} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {HistoryEmbeddingsBrowserProxyImpl} from './browser_proxy.js';
import {getTemplate} from './history_embeddings.html.js';
import type {SearchQuery, SearchResult, SearchResultItem} from './history_embeddings.mojom-webui.js';
import {UserFeedback} from './history_embeddings.mojom-webui.js';

function jsDateToMojoDate(date: Date): Time {
  const windowsEpoch = Date.UTC(1601, 0, 1, 0, 0, 0, 0);
  const unixEpoch = Date.UTC(1970, 0, 1, 0, 0, 0, 0);
  const epochDeltaInMs = unixEpoch - windowsEpoch;
  const internalValue = BigInt(date.valueOf() + epochDeltaInMs) * BigInt(1000);
  return {internalValue};
}

/* Minimum time the loading state should be visible. This is to prevent the
 * loading animation from flashing. */
export const LOADING_STATE_MINIMUM_MS = 300;

export interface HistoryEmbeddingsElement {
  $: {
    feedbackButtons: CrFeedbackButtonsElement,
    heading: HTMLElement,
    loading: HTMLElement,
    sharedMenu: CrLazyRenderElement<CrActionMenuElement>,
  };
}

export type HistoryEmbeddingsMoreActionsClickEvent =
    CustomEvent<SearchResultItem>;

declare global {
  interface HTMLElementEventMap {
    'more-from-site-click': HistoryEmbeddingsMoreActionsClickEvent;
    'remove-item-click': HistoryEmbeddingsMoreActionsClickEvent;
  }
}

const HistoryEmbeddingsElementBase = I18nMixin(PolymerElement);

export class HistoryEmbeddingsElement extends HistoryEmbeddingsElementBase {
  static get is() {
    return 'cr-history-embeddings';
  }

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

  static get properties() {
    return {
      clickedIndices_: Array,
      numCharsForQuery: Number,
      feedbackState_: {
        type: String,
        value: CrFeedbackOption.UNSPECIFIED,
      },
      loading_: Boolean,
      searchResult_: Object,
      searchQuery: String,
      timeRangeStart: Object,
      isEmpty: {
        type: Boolean,
        reflectToAttribute: true,
        value: true,
        computed: 'computeIsEmpty_(loading_, searchResult_.items.length)',
        notify: true,
      },
    };
  }

  static get observers() {
    return [
      'onSearchQueryChanged_(searchQuery, timeRangeStart)',
    ];
  }

  private actionMenuItem_: SearchResultItem|null = null;
  private browserProxy_ = HistoryEmbeddingsBrowserProxyImpl.getInstance();
  private clickedIndices_: Set<number> = new Set();
  private feedbackState_: CrFeedbackOption;
  private loading_ = false;
  private loadingStateMinimumMs_ = LOADING_STATE_MINIMUM_MS;
  private queryResultMinAge_ = QUERY_RESULT_MINIMUM_AGE;
  private searchResult_: SearchResult;
  private searchTimestamp_: number = 0;
  /**
   * When this is non-null, that means there's a SearchResult 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;
  private eventTracker_: EventTracker = new EventTracker();
  isEmpty: boolean;
  numCharsForQuery: number = 0;
  private numCharsForLastResultQuery_: number = 0;
  searchQuery: string;
  timeRangeStart?: Date;
  private searchResultChangedId_: number|null = null;

  override connectedCallback() {
    super.connectedCallback();
    this.eventTracker_.add(window, 'beforeunload', () => {
      // Flush any metrics or logs when the user leaves the page, such as
      // closing the tab or navigating to another URL.
      this.flushDebouncedUserMetrics_(/* forceFlush= */ true);
    });
    this.searchResultChangedId_ =
        this.browserProxy_.callbackRouter.searchResultChanged.addListener(
            this.searchResultChanged_.bind(this));
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    // Flush any metrics or logs when the component is removed, which can
    // happen if there are no history embedding results left or if the user
    // navigated to another history page.
    this.flushDebouncedUserMetrics_(/* forceFlush= */ true);
    this.eventTracker_.removeAll();
    if (this.searchResultChangedId_ !== null) {
      this.browserProxy_.callbackRouter.removeListener(
          this.searchResultChangedId_);
      this.searchResultChangedId_ = null;
    }
  }

  private computeIsEmpty_(): boolean {
    return !this.loading_ && this.searchResult_?.items.length === 0;
  }

  private getHeadingText_(): string {
    if (this.loading_) {
      return this.i18n('historyEmbeddingsHeadingLoading', this.searchQuery);
    }
    return this.i18n('historyEmbeddingsHeading', this.searchQuery);
  }

  private onFeedbackSelectedOptionChanged_(
      e: CustomEvent<{value: CrFeedbackOption}>) {
    this.feedbackState_ = e.detail.value;
    switch (e.detail.value) {
      case CrFeedbackOption.UNSPECIFIED:
        this.browserProxy_.setUserFeedback(
            UserFeedback.kUserFeedbackUnspecified);
        return;
      case CrFeedbackOption.THUMBS_UP:
        this.browserProxy_.setUserFeedback(UserFeedback.kUserFeedbackPositive);
        return;
      case CrFeedbackOption.THUMBS_DOWN:
        this.browserProxy_.setUserFeedback(UserFeedback.kUserFeedbackNegative);
        return;
    }
  }

  private onMoreActionsClick_(e: DomRepeatEvent<SearchResultItem>) {
    const target = e.target as HTMLElement;
    const item = e.model.item;
    this.actionMenuItem_ = item;
    this.$.sharedMenu.get().showAt(target);
  }

  private onMoreFromSiteClick_() {
    assert(this.actionMenuItem_);
    this.dispatchEvent(new CustomEvent(
        'more-from-site-click',
        {detail: this.actionMenuItem_, bubbles: true, composed: true}));
    this.$.sharedMenu.get().close();
  }

  private onRemoveFromHistoryClick_() {
    assert(this.actionMenuItem_);
    this.splice(
        'searchResult_.items',
        this.searchResult_.items.indexOf(this.actionMenuItem_), 1);
    this.dispatchEvent(new CustomEvent(
        'remove-item-click',
        {detail: this.actionMenuItem_, bubbles: true, composed: true}));
    this.$.sharedMenu.get().close();
  }

  private onResultClick_(e: DomRepeatEvent<SearchResultItem>) {
    this.dispatchEvent(new CustomEvent('result-click', {detail: e.model.item}));

    this.dispatchEvent(new CustomEvent('record-history-link-click', {
      bubbles: true,
      composed: true,
      detail: {
        resultType: HistoryResultType.EMBEDDINGS,
        index: e.model.index,
      },
    }));

    this.clickedIndices_.add(e.model.index);
    this.browserProxy_.recordSearchResultsMetrics(true, true);
  }

  private onSearchQueryChanged_() {
    // Flush any old results metrics before overwriting the member variable.
    this.flushDebouncedUserMetrics_();
    this.clickedIndices_.clear();

    // Cache the amount of characters that the user typed for this query so
    // that it can be sent with the quality log since `numCharsForQuery` will
    // immediately change when a new query is performed.
    this.numCharsForLastResultQuery_ = this.numCharsForQuery;

    this.loading_ = true;
    const query: SearchQuery = {
      query: this.searchQuery,
      timeRangeStart:
          this.timeRangeStart ? jsDateToMojoDate(this.timeRangeStart) : null,
    };
    this.searchTimestamp_ = performance.now();
    this.browserProxy_.search(query);
  }

  private searchResultChanged_(result: SearchResult) {
    // Artificial delay for UX. Note, timeout is always used for consistency,
    // and this can affect test behavior, so don't change to direct calls
    // even if no additional delay is necessary.
    setTimeout(
        this.searchResultChangedImpl_.bind(this, result),
        Math.max(
            0,
            this.searchTimestamp_ + this.loadingStateMinimumMs_ -
                performance.now()));
  }

  private searchResultChangedImpl_(result: SearchResult) {
    if (result.query !== this.searchQuery) {
      // Results are for an outdated query. Skip these results.
      return;
    }

    // Reset feedback state for new results.
    this.feedbackState_ = CrFeedbackOption.UNSPECIFIED;

    this.searchResult_ = result;
    this.loading_ = false;

    this.resultPendingMetricsTimestamp_ = performance.now();
  }

  /**
   * Flushes any pending query result metric or log waiting to be logged.
   */
  private flushDebouncedUserMetrics_(forceFlush = false) {
    if (this.resultPendingMetricsTimestamp_ === null) {
      return;
    }
    const userClickedResult = this.clickedIndices_.size > 0;
    // Search results are fetched as the user is typing, so make sure that
    // the last set of results were visible on the page for at least 2s. This is
    // to avoid logging results that may have been transient as the user was
    // still typing their full query.
    const resultsWereStable =
        (performance.now() - this.resultPendingMetricsTimestamp_) >=
        this.queryResultMinAge_;
    const canLog = resultsWereStable || forceFlush;

    // Record a metric if a user did not click any results.
    if (canLog && !userClickedResult) {
      const nonEmptyResults: boolean =
          this.searchResult_.items && this.searchResult_.items.length > 0;
      this.browserProxy_.recordSearchResultsMetrics(nonEmptyResults, false);
    }

    if (canLog) {
      this.browserProxy_.sendQualityLog(
          Array.from(this.clickedIndices_), this.numCharsForLastResultQuery_);
    }

    // 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;
  }

  overrideLoadingStateMinimumMsForTesting(ms: number) {
    this.loadingStateMinimumMs_ = ms;
  }

  overrideQueryResultMinAgeForTesting(ms: number) {
    this.queryResultMinAge_ = ms;
  }

  searchResultChangedForTesting(result: SearchResult) {
    this.searchResultChanged_(result);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-history-embeddings': HistoryEmbeddingsElement;
  }
}

customElements.define(HistoryEmbeddingsElement.is, HistoryEmbeddingsElement);