chromium/chrome/browser/resources/engagement/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 {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {CustomElement} from 'chrome://resources/js/custom_element.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import type {Url} from 'chrome://resources/mojo/url/mojom/url.mojom-webui.js';

import {getTemplate} from './app.html.js';
import type {SiteEngagementDetails, SiteEngagementDetailsProviderInterface} from './site_engagement_details.mojom-webui.js';
import {SiteEngagementDetailsProvider} from './site_engagement_details.mojom-webui.js';

/**
 * Rounds the supplied value to two decimal places of accuracy.
 */
function roundScore(score: number): number {
  return Number(Math.round(score * 100) / 100);
}

/**
 * Compares two SiteEngagementDetails objects based on |sortKey|.
 * @param sortKey The name of the property to sort by.
 * @return A negative number if |a| should be ordered before |b|, a
 *     positive number otherwise.
 */
function compareTableItem(
    sortKey: string, a: {[k: string]: any}, b: {[k: string]: any}): number {
  const val1 = a[sortKey];
  const val2 = b[sortKey];

  // Compare the hosts of the origin ignoring schemes.
  if (sortKey === 'origin') {
    return new URL(val1.url).host > new URL(val2.url).host ? 1 : -1;
  }

  if (sortKey === 'baseScore' || sortKey === 'bonusScore' ||
      sortKey === 'totalScore') {
    return val1 - val2;
  }

  assertNotReached('Unsupported sort key: ' + sortKey);
}


export class SiteEngagementAppElement extends CustomElement {
  static get is() {
    return 'site-engagement-app';
  }

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

  private engagementTableBody: HTMLElement|null = null;
  private info: SiteEngagementDetails[]|null = null;
  engagementDetailsProvider: SiteEngagementDetailsProviderInterface =
      SiteEngagementDetailsProvider.getRemote();
  private updateInterval: number|null = null;
  private showWebUiPages: boolean = false;
  private sortKey: string = 'totalScore';
  private sortReverse: boolean = true;
  private whenPopulatedResolver: PromiseResolver<void> = new PromiseResolver();

  connectedCallback() {
    const engagementTableHeader =
        this.getRequiredElement('#engagement-table-header');
    this.engagementTableBody =
        this.getRequiredElement('#engagement-table-body');

    const headers = engagementTableHeader.children;
    for (let i = 0; i < headers.length; i++) {
      headers[i]!.addEventListener('click', e => {
        const target = e.target as HTMLElement;
        const newSortKey = target.getAttribute('sort-key');
        assert(newSortKey);
        if (this.sortKey === newSortKey) {
          this.sortReverse = !this.sortReverse;
        } else {
          this.sortKey = newSortKey;
          this.sortReverse = false;
        }
        const oldSortColumn = this.getRequiredElement('.sort-column');
        oldSortColumn.classList.remove('sort-column');
        target.classList.add('sort-column');
        target.toggleAttribute('sort-reverse', this.sortReverse);
        this.renderTable();
      });
    }

    const showWebUiPagesCheckbox =
        this.getRequiredElement<HTMLInputElement>('#show-webui-pages-checkbox');
    showWebUiPagesCheckbox.addEventListener(
        'change',
        () => this.handleShowWebUiPages(showWebUiPagesCheckbox.checked));

    this.updateEngagementTable();
    this.enableAutoupdate();
  }

  /**
   * Creates a single row in the engagement table.
   * @param info The info to create the row from.
   */
  private createRow(info: SiteEngagementDetails): HTMLElement {
    const originCell = document.createElement('td');
    originCell.classList.add('origin-cell');
    originCell.textContent = info.origin.url;

    const baseScoreInput = document.createElement('input');
    baseScoreInput.classList.add('base-score-input');
    baseScoreInput.addEventListener('focus', () => this.disableAutoupdate());
    baseScoreInput.addEventListener('blur', () => this.enableAutoupdate());
    baseScoreInput.value = String(info.baseScore);

    const baseScoreCell = document.createElement('td');
    baseScoreCell.classList.add('base-score-cell');
    baseScoreCell.appendChild(baseScoreInput);

    const bonusScoreCell = document.createElement('td');
    bonusScoreCell.classList.add('bonus-score-cell');
    bonusScoreCell.textContent = String(info.installedBonus);

    const totalScoreCell = document.createElement('td');
    totalScoreCell.classList.add('total-score-cell');
    totalScoreCell.textContent = String(info.totalScore);

    const engagementBar = document.createElement('div');
    engagementBar.classList.add('engagement-bar');
    engagementBar.style.width = (info.totalScore * 4) + 'px';

    const engagementBarCell = document.createElement('td');
    engagementBarCell.classList.add('engagement-bar-cell');
    engagementBarCell.appendChild(engagementBar);

    const row = document.createElement('tr');
    row.appendChild(originCell);
    row.appendChild(baseScoreCell);
    row.appendChild(bonusScoreCell);
    row.appendChild(totalScoreCell);
    row.appendChild(engagementBarCell);

    baseScoreInput.addEventListener(
        'change',
        (e: Event) =>
            this.handleBaseScoreChange(info.origin, engagementBar, e));

    return row;
  }

  disableAutoupdate() {
    if (this.updateInterval) {
      clearInterval(this.updateInterval);
    }
    this.updateInterval = null;
  }

  private enableAutoupdate() {
    if (this.updateInterval) {
      clearInterval(this.updateInterval);
    }
    this.updateInterval = setInterval(() => this.updateEngagementTable(), 5000);
  }

  /**
   * Sets the base engagement score when a score input is changed.
   * Resets the length of engagement-bar-cell to match the new score.
   * Also resets the update interval.
   * @param origin The origin of the engagement score to set.
   */
  private handleBaseScoreChange(origin: Url, barCell: HTMLElement, e: Event) {
    const baseScoreInput = e.target as HTMLInputElement;
    this.engagementDetailsProvider.setSiteEngagementBaseScoreForUrl(
        origin, parseFloat(baseScoreInput.value));
    barCell.style.width = (parseFloat(baseScoreInput.value) * 4) + 'px';
    baseScoreInput.blur();
    this.enableAutoupdate();
  }

  /**
   * Adds a new origin with the given base score.
   * @param originInput The text input containing the origin to add.
   * @param scoreInput The text input containing the score to add.
   */
  private handleAddOrigin(
      originInput: HTMLInputElement, scoreInput: HTMLInputElement) {
    try {
      // Validate the URL. If we don't validate here, IPC will kill this
      // renderer on invalid URLs. Other checks like scheme are done on the
      // browser side.
      new URL(originInput.value);
    } catch {
      return;
    }
    const origin: Url = {url: originInput.value};
    const score = parseFloat(scoreInput.value);

    this.engagementDetailsProvider.setSiteEngagementBaseScoreForUrl(
        origin, score);
    scoreInput.blur();
    this.updateEngagementTable();
    this.enableAutoupdate();
  }

  /**
   * Show chrome:// and chrome-untrusted:// pages.
   */
  private handleShowWebUiPages(show: boolean) {
    this.showWebUiPages = show;
    this.renderTable();
  }

  /**
   * Remove all rows from the engagement table.
   */
  private clearTable() {
    assert(this.engagementTableBody);
    this.engagementTableBody.innerHTML = window.trustedTypes!.emptyHTML;
  }

  /**
   * Sort the engagement info based on |sortKey| and |sortReverse|.
   */
  private sortInfo() {
    assert(this.info);
    this.info.sort((a, b) => {
      return (this.sortReverse ? -1 : 1) * compareTableItem(this.sortKey, a, b);
    });
  }

  /**
   * Regenerates the engagement table from |info|.
   */
  private renderTable() {
    this.clearTable();
    this.sortInfo();

    assert(this.info);
    this.info.forEach((info) => {
      if (!this.showWebUiPages &&
          (info.origin.url.startsWith('chrome://') ||
           info.origin.url.startsWith('chrome-untrusted://'))) {
        return;
      }

      // Round all scores to 2 decimal places.
      info.baseScore = roundScore(info.baseScore);
      info.installedBonus = roundScore(info.installedBonus);
      info.totalScore = roundScore(info.totalScore);

      assert(this.engagementTableBody);
      this.engagementTableBody.appendChild(this.createRow(info));
    });

    // Add another row for adding a new origin.
    const originInput = document.createElement('input');
    originInput.classList.add('origin-input');
    originInput.addEventListener('focus', () => this.disableAutoupdate());
    originInput.addEventListener('blur', () => this.enableAutoupdate());
    originInput.value = 'http://example.com';

    const originCell = document.createElement('td');
    originCell.appendChild(originInput);

    const baseScoreInput = document.createElement('input');
    baseScoreInput.classList.add('base-score-input');
    baseScoreInput.addEventListener('focus', () => this.disableAutoupdate());
    baseScoreInput.addEventListener('blur', () => this.enableAutoupdate());
    baseScoreInput.value = '0';

    const baseScoreCell = document.createElement('td');
    baseScoreCell.classList.add('base-score-cell');
    baseScoreCell.appendChild(baseScoreInput);

    const addButton = document.createElement('button');
    addButton.textContent = 'Add';
    addButton.classList.add('add-origin-button');

    const buttonCell = document.createElement('td');
    buttonCell.colSpan = 2;
    buttonCell.classList.add('base-score-cell');
    buttonCell.appendChild(addButton);

    const row = document.createElement('tr');
    row.appendChild(originCell);
    row.appendChild(baseScoreCell);
    row.appendChild(buttonCell);
    addButton.addEventListener(
        'click', () => this.handleAddOrigin(originInput, baseScoreInput));

    assert(this.engagementTableBody);
    this.engagementTableBody.appendChild(row);
  }

  /**
   * Retrieve site engagement info and render the engagement table.
   */
  private async updateEngagementTable() {
    // Populate engagement table.
    this.info =
        (await this.engagementDetailsProvider.getSiteEngagementDetails()).info;
    this.renderTable();
    this.whenPopulatedResolver.resolve();
  }

  whenPopulatedForTest() {
    return this.whenPopulatedResolver.promise;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'site-engagement-app': SiteEngagementAppElement;
  }
}

customElements.define(SiteEngagementAppElement.is, SiteEngagementAppElement);