chromium/chrome/browser/resources/whats_new/whats_new_app.ts

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

import './strings.m.js';

import type {ClickInfo} from 'chrome://resources/js/browser_command.mojom-webui.js';
import {Command} from 'chrome://resources/js/browser_command.mojom-webui.js';
import {BrowserCommandProxy} from 'chrome://resources/js/browser_command/browser_command_proxy.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {isChromeOS} from 'chrome://resources/js/platform.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {JSTime, TimeDelta} from 'chrome://resources/mojo/mojo/public/mojom/base/time.mojom-webui.js';
import type {Url} from 'chrome://resources/mojo/url/mojom/url.mojom-webui.js';

import {ModulePosition, ScrollDepth} from './whats_new.mojom-webui.js';
import {getCss} from './whats_new_app.css.js';
import {getHtml} from './whats_new_app.html.js';
import {WhatsNewProxyImpl} from './whats_new_proxy.js';

enum EventType {
  BROWSER_COMMAND = 'browser_command',
  PAGE_LOADED = 'page_loaded',
  MODULE_IMPRESSION = 'module_impression',
  EXPLORE_MORE_OPEN = 'explore_more_open',
  EXPLORE_MORE_CLOSE = 'explore_more_close',
  SCROLL = 'scroll',
  TIME_ON_PAGE_MS = 'time_on_page_ms',
  GENERAL_LINK_CLICK = 'general_link_click',
  MODULES_RENDERED = 'modules_rendered',
}

enum SectionType {
  SPOTLIGHT = 'spotlight',
  EXPLORE_MORE = 'explore_more',
}

// Used to map a section and order value to the ModulePosition mojo type.
const kModulePositionsMap: Record<SectionType, ModulePosition[]> = {
  [SectionType.SPOTLIGHT]: [
    ModulePosition.kSpotlight1,
    ModulePosition.kSpotlight2,
    ModulePosition.kSpotlight3,
    ModulePosition.kSpotlight4,
  ],
  [SectionType.EXPLORE_MORE]: [
    ModulePosition.kExploreMore1,
    ModulePosition.kExploreMore2,
    ModulePosition.kExploreMore3,
    ModulePosition.kExploreMore4,
    ModulePosition.kExploreMore5,
    ModulePosition.kExploreMore6,
  ],
};

// TODO(crbug.com/342172972): Remove legacy browser command format.
interface LegacyBrowserCommandData {
  commandId: number;
  clickInfo: ClickInfo;
}

interface BrowserCommandData {
  event: EventType.BROWSER_COMMAND;
  commandId: number;
  clickInfo: ClickInfo;
}

interface VersionPageLoadedMetric {
  event: EventType.PAGE_LOADED;
  type: 'version';
  version: number;
  page_uid?: string;
}

interface EditionPageLoadedMetric {
  event: EventType.PAGE_LOADED;
  type: 'edition';
  version: null;
  page_uid: string;
}

interface ModuleImpressionMetric {
  event: EventType.MODULE_IMPRESSION;
  module_name?: string;
  section?: SectionType;
  order?: '1'|'2'|'3'|'4'|'5'|'6';
}

interface ExploreMoreOpenMetric {
  event: EventType.EXPLORE_MORE_OPEN;
  module_name: 'archive';
}

interface ExploreMoreCloseMetric {
  event: EventType.EXPLORE_MORE_CLOSE;
  module_name: 'archive';
}

interface ScrollDepthMetric {
  event: EventType.SCROLL;
  percent_scrolled: '25'|'50'|'75'|'100';
}

interface TimeOnPageMetric {
  event: EventType.TIME_ON_PAGE_MS;
  time: number;
}

interface GeneralLinkClickMetric {
  event: EventType.GENERAL_LINK_CLICK;
  link_text: string;
  link_type: string;
  link_url: string;
  module_name?: string;
  section?: SectionType;
  order?: '1'|'2'|'3'|'4'|'5'|'6';
}

interface ModulesRenderedMetric {
  event: EventType.MODULES_RENDERED;
}

type PageLoadedMetric = VersionPageLoadedMetric|EditionPageLoadedMetric;
type BrowserCommand = LegacyBrowserCommandData|BrowserCommandData;
type MetricData = PageLoadedMetric|ModuleImpressionMetric|ExploreMoreOpenMetric|
    ExploreMoreCloseMetric|ScrollDepthMetric|TimeOnPageMetric|
    GeneralLinkClickMetric|ModulesRenderedMetric;

interface EventData {
  data: BrowserCommand|MetricData;
}

// Narrow the type of the message data. This is necessary for the
// legacy message format that does not supply an event name.
function isBrowserCommand(messageData: BrowserCommand|
                          MetricData): messageData is BrowserCommand {
  // TODO(crbug.com/342172972): Remove legacy browser command format checks.
  if (Object.hasOwn(messageData, 'event')) {
    return (messageData as BrowserCommandData | MetricData).event ===
        EventType.BROWSER_COMMAND;
  } else {
    return Object.hasOwn(messageData, 'commandId');
  }
}

function handleBrowserCommand(messageData: BrowserCommand) {
  if (!Object.values(Command).includes(messageData.commandId)) {
    return;
  }
  const {commandId} = messageData;
  const handler = BrowserCommandProxy.getInstance().handler;
  handler.canExecuteCommand(commandId).then(({canExecute}) => {
    if (canExecute) {
      handler.executeCommand(commandId, messageData.clickInfo);
      const pageHandler = WhatsNewProxyImpl.getInstance().handler;
      pageHandler.recordBrowserCommandExecuted();
    } else {
      console.warn('Received invalid command: ' + commandId);
    }
  });
}

function handlePageLoadMetric(data: PageLoadedMetric, isAutoOpen: boolean) {
  const {handler} = WhatsNewProxyImpl.getInstance();
  const now: JSTime = {msec: Date.now()};
  handler.recordTimeToLoadContent(now);

  // Record initial scroll depth as 0%.
  handler.recordScrollDepth(ScrollDepth.k0);

  switch (data.type) {
    case 'version':
      if (Number.isInteger(data.version)) {
        const {handler} = WhatsNewProxyImpl.getInstance();
        handler.recordVersionPageLoaded(isAutoOpen);
      }
      break;
    case 'edition':
      const {handler} = WhatsNewProxyImpl.getInstance();
      handler.recordEditionPageLoaded(data.page_uid, isAutoOpen);
      break;
    default:
      console.warn('Unrecognized page version: ' + (data as any)!.type);
  }
}

function handleScrollDepthMetric(data: ScrollDepthMetric) {
  let scrollDepth;
  // Embedded page never sends scroll depth 0%. This value is created in
  // handlePageLoadMetric instead.
  switch (data.percent_scrolled) {
    case '25':
      scrollDepth = ScrollDepth.k25;
      break;
    case '50':
      scrollDepth = ScrollDepth.k50;
      break;
    case '75':
      scrollDepth = ScrollDepth.k75;
      break;
    case '100':
      scrollDepth = ScrollDepth.k100;
      break;
  }
  if (scrollDepth) {
    const {handler} = WhatsNewProxyImpl.getInstance();
    handler.recordScrollDepth(scrollDepth);
  } else {
    console.warn('Unrecognized scroll percentage: ', data.percent_scrolled);
  }
}

// Parse `section` and `order` values from the untrusted source.
function parseOrder(
    section: string|undefined, order: string|undefined): ModulePosition {
  // Reject messages that send falsy `section` or `order` values.
  if (!section || !order) {
    return ModulePosition.kUndefined;
  }

  // Ensure `section` is one of the defined enum values.
  if (!(Object.values(SectionType).includes(section as SectionType))) {
    return ModulePosition.kUndefined;
  }

  const moduleSection = kModulePositionsMap[section as SectionType];
  const orderAsNumber = Number.parseInt(order, 10);
  // Ensure `order` is a number within the 1-based range of the given section.
  if (Number.isNaN(orderAsNumber) || orderAsNumber > moduleSection.length ||
      orderAsNumber < 1) {
    return ModulePosition.kUndefined;
  }

  // Get ModulePosition enum from validated message parameters.
  return moduleSection[orderAsNumber - 1] as ModulePosition;
}

// Replace first letter with its uppercase equivalent.
function uppercaseFirstLetter(word: string) {
  return word.replace(/^\w/, firstLetter => firstLetter.toUpperCase());
}

// Convert kebab-case string (e.g. my-module-name) to PascalCase (e.g.
// MyModuleName).
function kebabCaseToCamelCase(input: string) {
  return input
      // Split on hyphen to remove it.
      .split('-')
      // Uppercase first letter of each word.
      .map(uppercaseFirstLetter)
      // Join back into contiguous string.
      .join('');
}

// Convert legacy module names (i.e. module uid) to a format that can
// be captured in metrics.
//
// Previous module names were created in the format `NNN-module-name`.
// These module names cannot be recorded in metrics as-is, due to the
// hyphens. They must be switched to a PascalCase format. New module
// names will not follow this format, therefore will be ignored if they
// don't contain a hyphen.
//
// Exported for testing purposes only.
export function formatModuleName(moduleName: string) {
  if (!moduleName.includes('-')) {
    return moduleName;
  }
  // Remove the 3 numbers at the beginning of the name (`NNN-`)
  const withoutPrefix = moduleName.replace(/^\d{3}-/, '');
  return kebabCaseToCamelCase(withoutPrefix);
}

function handleModuleImpression(data: ModuleImpressionMetric) {
  // Reject falsy `module_name`, including empty strings.
  if (!data.module_name) {
    return;
  }
  const position = parseOrder(data.section, data.order);
  const {handler} = WhatsNewProxyImpl.getInstance();
  handler.recordModuleImpression(formatModuleName(data.module_name), position);
}

function handleModuleLinkClicked(data: GeneralLinkClickMetric) {
  // Reject falsy `module_name`, including empty strings.
  if (!data.module_name) {
    return;
  }
  const position = parseOrder(data.section, data.order);
  const {handler} = WhatsNewProxyImpl.getInstance();
  handler.recordModuleLinkClicked(formatModuleName(data.module_name), position);
}

function handleTimeOnPageMetric(data: TimeOnPageMetric) {
  if (Number.isInteger(data.time) && data.time > 0) {
    const {handler} = WhatsNewProxyImpl.getInstance();
    // Event contains time in milliseconds. Convert to microseconds.
    const delta: TimeDelta = {microseconds: BigInt(data.time) * 1000n};
    handler.recordTimeOnPage(delta);
  } else {
    console.warn('Invalid time: ', data.time);
  }
}

export class WhatsNewAppElement extends CrLitElement {
  static get is() {
    return 'whats-new-app';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      url_: {type: String},
    };
  }

  protected url_: string = '';

  private isAutoOpen_: boolean = false;
  private eventTracker_: EventTracker = new EventTracker();

  constructor() {
    super();

    const queryParams = new URLSearchParams(window.location.search);

    // Indicates this tab was added automatically by the browser.
    this.isAutoOpen_ = queryParams.has('auto');

    // There are no subpages in What's New. Also remove the query param here
    // since its value is recorded.
    window.history.replaceState(undefined /* stateObject */, '', '/');
  }

  override connectedCallback() {
    super.connectedCallback();

    WhatsNewProxyImpl.getInstance()
        .handler.getServerUrl(loadTimeData.getBoolean('isStaging'))
        .then(({url}: {url: Url}) => this.handleUrlResult_(url.url));
  }

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

  /**
   * Handles the URL result of sending the initialize WebUI message.
   * @param url The What's New URL to use in the iframe.
   */
  private handleUrlResult_(url: string|null) {
    if (!url) {
      // This occurs in the special case of tests where we don't want to load
      // remote content.
      return;
    }

    const latest = this.isAutoOpen_ && !isChromeOS ? 'true' : 'false';
    url += url.includes('?') ? '&' : '?';
    if (loadTimeData.getBoolean('isWhatsNewV2')) {
      // The browser has auto-opened the page due to an upgrade.
      // Let the embedded page know to display the "up to date" banner.
      this.url_ = url.concat(`updated=${latest}`);
    } else {
      // The latest version of the page is being shown. Do not redirect.
      this.url_ = url.concat(`latest=${latest}`);
    }

    this.eventTracker_.add(
        window, 'message',
        (event: Event) => this.handleMessage_(event as MessageEvent));
  }

  private handleMessage_(event: MessageEvent) {
    if (!this.url_) {
      return;
    }

    const iframeUrl = new URL(this.url_);
    if (!event.data || event.origin !== iframeUrl.origin) {
      return;
    }

    const data = (event.data as EventData).data;
    if (!data) {
      return;
    }

    if (isBrowserCommand(data)) {
      handleBrowserCommand(data);
      return;
    }

    const {handler} = WhatsNewProxyImpl.getInstance();
    switch (data.event) {
      case EventType.PAGE_LOADED:
        handlePageLoadMetric(data, this.isAutoOpen_);
        break;
      case EventType.MODULE_IMPRESSION:
        handleModuleImpression(data);
        break;
      case EventType.MODULES_RENDERED:
        // Ignored.
        break;
      case EventType.EXPLORE_MORE_OPEN:
        handler.recordExploreMoreToggled(true);
        break;
      case EventType.EXPLORE_MORE_CLOSE:
        handler.recordExploreMoreToggled(false);
        break;
      case EventType.SCROLL:
        handleScrollDepthMetric(data);
        break;
      case EventType.TIME_ON_PAGE_MS:
        handleTimeOnPageMetric(data);
        break;
      case EventType.GENERAL_LINK_CLICK:
        handleModuleLinkClicked(data);
        break;
      default:
        console.warn('Unrecognized message.', data);
    }
  }
}
customElements.define(WhatsNewAppElement.is, WhatsNewAppElement);