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