| // 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 {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', |
| VIDEO_STARTED = 'video_started', |
| VIDEO_ENDED = 'video_ended', |
| PLAY_CLICKED = 'play_clicked', |
| PAUSE_CLICKED = 'pause_clicked', |
| RESTART_CLICKED = 'restart_clicked', |
| // Refresh metrics. |
| QR_CODE_TOGGLE_OPEN = 'qr_code_toggle_open', |
| QR_CODE_TOGGLE_CLOSE = 'qr_code_toggle_close', |
| NAV_CLICK = 'nav_click', |
| FEATURE_TILE_NAVIGATION = 'feature_tile_navigation', |
| CAROUSEL_SCROLL_BUTTON_CLICK = 'carousel_scroll_button_click', |
| EXPEND_MEDIA = 'expand_media', |
| CLOSE_EXPANDED_MEDIA = 'close_expanded_media', |
| CTA_CLICK = 'cta_click', |
| NEXT_BUTTON_CLICK = 'next_button_click', |
| } |
| |
| 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, |
| ], |
| }; |
| |
| interface BrowserCommand { |
| 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; |
| } |
| |
| interface VideoStartedMetric { |
| event: EventType.VIDEO_STARTED; |
| module_name?: string; |
| section?: SectionType; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| interface VideoEndedMetric { |
| event: EventType.VIDEO_ENDED; |
| module_name?: string; |
| section?: SectionType; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| interface PlayClickedMetric { |
| event: EventType.PLAY_CLICKED; |
| module_name?: string; |
| section?: SectionType; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| interface PauseClickedMetric { |
| event: EventType.PAUSE_CLICKED; |
| module_name?: string; |
| section?: SectionType; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| interface RestartClickedMetric { |
| event: EventType.RESTART_CLICKED; |
| module_name?: string; |
| section?: SectionType; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| interface QrCodeToggleOpenMetric { |
| event: EventType.QR_CODE_TOGGLE_OPEN; |
| // Not a What's New module. |
| module_name?: string; |
| } |
| |
| interface QrCodeToggleCloseMetric { |
| event: EventType.QR_CODE_TOGGLE_CLOSE; |
| // Not a What's New module. |
| module_name?: string; |
| } |
| |
| interface NavClickMetric { |
| event: EventType.NAV_CLICK; |
| // Not a What's New module. |
| module_name?: string; |
| link_text: string; |
| link_url: string; |
| link_type: 'internal'|'external'; |
| } |
| |
| interface FeatureTileNavigationMetric { |
| event: EventType.FEATURE_TILE_NAVIGATION; |
| // Not a What's New module. |
| module_name?: string; |
| navigation_label: string; |
| position: string; |
| } |
| |
| interface CarouselScrollButtonClickMetric { |
| event: EventType.CAROUSEL_SCROLL_BUTTON_CLICK; |
| // Not a What's New module. |
| module_name?: string; |
| navigation_label: string; |
| position: string; |
| } |
| |
| interface ExpandMediaMetric { |
| event: EventType.EXPEND_MEDIA; |
| module_name?: string; |
| section: 'spotlight'; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| interface CloseExpandedMediaMetric { |
| event: EventType.CLOSE_EXPANDED_MEDIA; |
| module_name?: string; |
| section: 'spotlight'; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| interface CtaClickMetric { |
| event: EventType.CTA_CLICK; |
| module_name?: string; |
| section: 'spotlight'; |
| link_text: string; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| interface NextButtonClickMetric { |
| event: EventType.NEXT_BUTTON_CLICK; |
| module_name?: string; |
| section: 'spotlight'; |
| link_text: string; |
| order?: '1'|'2'|'3'|'4'|'5'|'6'; |
| } |
| |
| type PageLoadedMetric = VersionPageLoadedMetric|EditionPageLoadedMetric; |
| type MetricData = PageLoadedMetric|ModuleImpressionMetric|ExploreMoreOpenMetric| |
| ExploreMoreCloseMetric|ScrollDepthMetric|TimeOnPageMetric| |
| GeneralLinkClickMetric|ModulesRenderedMetric|VideoStartedMetric| |
| VideoEndedMetric|PlayClickedMetric|PauseClickedMetric|RestartClickedMetric| |
| QrCodeToggleOpenMetric|QrCodeToggleCloseMetric|NavClickMetric| |
| FeatureTileNavigationMetric|CarouselScrollButtonClickMetric| |
| ExpandMediaMetric|CloseExpandedMediaMetric|CtaClickMetric| |
| NextButtonClickMetric; |
| |
| interface EventData { |
| data: BrowserCommand|MetricData; |
| } |
| |
| 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 = new Date(); |
| 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; |
| default: |
| 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 handleModuleEvent( |
| data: ModuleImpressionMetric|GeneralLinkClickMetric|VideoStartedMetric| |
| VideoEndedMetric|PlayClickedMetric|PauseClickedMetric|RestartClickedMetric| |
| ExpandMediaMetric|CloseExpandedMediaMetric) { |
| // Reject falsy `module_name`, including empty strings. |
| if (!data.module_name) { |
| return; |
| } |
| const position = parseOrder(data.section, data.order); |
| const {handler} = WhatsNewProxyImpl.getInstance(); |
| switch (data.event) { |
| case EventType.MODULE_IMPRESSION: |
| handler.recordModuleImpression( |
| formatModuleName(data.module_name), position); |
| break; |
| case EventType.GENERAL_LINK_CLICK: |
| handler.recordModuleLinkClicked( |
| formatModuleName(data.module_name), position); |
| break; |
| case EventType.VIDEO_STARTED: |
| handler.recordModuleVideoStarted( |
| formatModuleName(data.module_name), position); |
| break; |
| case EventType.VIDEO_ENDED: |
| handler.recordModuleVideoEnded( |
| formatModuleName(data.module_name), position); |
| break; |
| case EventType.PLAY_CLICKED: |
| handler.recordModulePlayClicked( |
| formatModuleName(data.module_name), position); |
| break; |
| case EventType.PAUSE_CLICKED: |
| handler.recordModulePauseClicked( |
| formatModuleName(data.module_name), position); |
| break; |
| case EventType.RESTART_CLICKED: |
| handler.recordModuleRestartClicked( |
| formatModuleName(data.module_name), position); |
| break; |
| case EventType.EXPEND_MEDIA: |
| WhatsNewProxyImpl.getInstance().handler.recordExpandMediaToggled( |
| data.module_name, true); |
| break; |
| case EventType.CLOSE_EXPANDED_MEDIA: |
| WhatsNewProxyImpl.getInstance().handler.recordExpandMediaToggled( |
| data.module_name, false); |
| break; |
| default: |
| break; |
| } |
| } |
| |
| 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 accessor 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('?') ? '&' : '?'; |
| // 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}`); |
| |
| 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; |
| } |
| |
| switch (data.event) { |
| case EventType.BROWSER_COMMAND: |
| handleBrowserCommand(data); |
| break; |
| case EventType.PAGE_LOADED: |
| handlePageLoadMetric(data, this.isAutoOpen_); |
| break; |
| case EventType.MODULES_RENDERED: |
| // Ignored. |
| break; |
| case EventType.EXPLORE_MORE_OPEN: |
| WhatsNewProxyImpl.getInstance().handler.recordExploreMoreToggled(true); |
| break; |
| case EventType.EXPLORE_MORE_CLOSE: |
| WhatsNewProxyImpl.getInstance().handler.recordExploreMoreToggled(false); |
| break; |
| case EventType.SCROLL: |
| handleScrollDepthMetric(data); |
| break; |
| case EventType.TIME_ON_PAGE_MS: |
| handleTimeOnPageMetric(data); |
| break; |
| case EventType.MODULE_IMPRESSION: |
| case EventType.GENERAL_LINK_CLICK: |
| case EventType.VIDEO_STARTED: |
| case EventType.VIDEO_ENDED: |
| case EventType.PLAY_CLICKED: |
| case EventType.PAUSE_CLICKED: |
| case EventType.RESTART_CLICKED: |
| case EventType.EXPEND_MEDIA: |
| case EventType.CLOSE_EXPANDED_MEDIA: |
| handleModuleEvent(data); |
| break; |
| case EventType.QR_CODE_TOGGLE_OPEN: |
| WhatsNewProxyImpl.getInstance().handler.recordQrCodeToggled(true); |
| break; |
| case EventType.QR_CODE_TOGGLE_CLOSE: |
| WhatsNewProxyImpl.getInstance().handler.recordQrCodeToggled(false); |
| break; |
| case EventType.NAV_CLICK: |
| WhatsNewProxyImpl.getInstance().handler.recordNavClick(); |
| break; |
| case EventType.FEATURE_TILE_NAVIGATION: |
| WhatsNewProxyImpl.getInstance().handler.recordFeatureTileNavigation(); |
| break; |
| case EventType.CAROUSEL_SCROLL_BUTTON_CLICK: |
| WhatsNewProxyImpl.getInstance() |
| .handler.recordCarouselScrollButtonClick(); |
| break; |
| case EventType.CTA_CLICK: |
| WhatsNewProxyImpl.getInstance().handler.recordCtaClick(); |
| break; |
| case EventType.NEXT_BUTTON_CLICK: |
| WhatsNewProxyImpl.getInstance().handler.recordNextButtonClick(); |
| break; |
| default: |
| console.warn('Unrecognized message.', data); |
| } |
| } |
| } |
| customElements.define(WhatsNewAppElement.is, WhatsNewAppElement); |