blob: 36149143dd09cad7abb0e6a9f5b190b60989db1c [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './iframe.js';
import './realbox/realbox.js';
import './logo.js';
import './modules/modules.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
import 'chrome://resources/cr_elements/shared_style_css.m.js';
import {ClickInfo, Command} from 'chrome://resources/js/browser_command/browser_command.mojom-webui.js';
import {BrowserCommandProxy} from 'chrome://resources/js/browser_command/browser_command_proxy.js';
import {hexColorToSkColor, skColorToRgba} from 'chrome://resources/js/color_utils.js';
import {FocusOutlineManager} from 'chrome://resources/js/cr/ui/focus_outline_manager.m.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.m.js';
import {SkColor} from 'chrome://resources/mojo/skia/public/mojom/skcolor.mojom-webui.js';
import {DomIf, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {getTemplate} from './app.html.js';
import {BackgroundManager} from './background_manager.js';
import {CustomizeDialogPage} from './customize_dialog_types.js';
import {loadTimeData} from './i18n_setup.js';
import {IframeElement} from './iframe.js';
import {LogoElement} from './logo.js';
import {recordLoadDuration} from './metrics_utils.js';
import {ModuleRegistry} from './modules/module_registry.js';
import {PageCallbackRouter, PageHandlerRemote, Theme} from './new_tab_page.mojom-webui.js';
import {NewTabPageProxy} from './new_tab_page_proxy.js';
import {$$} from './utils.js';
import {Action as VoiceAction, recordVoiceAction} from './voice_search_overlay.js';
import {WindowProxy} from './window_proxy.js';
type ExecutePromoBrowserCommandData = {
commandId: Command,
clickInfo: ClickInfo,
};
type CanShowPromoWithBrowserCommandData = {
frameType: string,
messageType: string,
commandId: Command,
};
/**
* Elements on the NTP. This enum must match the numbering for NTPElement in
* enums.xml. These values are persisted to logs. Entries should not be
* renumbered, removed or reused.
*/
export enum NtpElement {
kOther = 0,
kBackground = 1,
kOneGoogleBar = 2,
kLogo = 3,
kRealbox = 4,
kMostVisited = 5,
kMiddleSlotPromo = 6,
kModule = 7,
kCustomize = 8,
}
const CUSTOMIZE_URL_PARAM: string = 'customize';
function recordClick(element: NtpElement) {
chrome.metricsPrivate.recordEnumerationValue(
'NewTabPage.Click', element, Object.keys(NtpElement).length);
}
// Adds a <script> tag that holds the lazy loaded code.
function ensureLazyLoaded() {
const script = document.createElement('script');
script.type = 'module';
script.src = './lazy_load.js';
document.body.appendChild(script);
}
export interface AppElement {
$: {
customizeDialogIf: DomIf,
oneGoogleBarClipPath: HTMLElement,
logo: LogoElement,
};
}
export class AppElement extends PolymerElement {
static get is() {
return 'ntp-app';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
oneGoogleBarIframePath_: {
type: String,
value: () => {
const params = new URLSearchParams();
params.set(
'paramsencoded',
btoa(window.location.search.replace(/^[?]/, '&')));
return `chrome-untrusted://new-tab-page/one-google-bar?${params}`;
},
},
oneGoogleBarLoaded_: {
type: Boolean,
observer: 'notifyOneGoogleBarDarkThemeEnabledChange_',
},
oneGoogleBarDarkThemeEnabled_: {
type: Boolean,
computed: `computeOneGoogleBarDarkThemeEnabled_(oneGoogleBarLoaded_,
theme_)`,
observer: 'notifyOneGoogleBarDarkThemeEnabledChange_',
},
theme_: {
observer: 'onThemeChange_',
type: Object,
},
showCustomizeDialog_: {
type: Boolean,
value: () =>
WindowProxy.getInstance().url.searchParams.has(CUSTOMIZE_URL_PARAM),
},
selectedCustomizeDialogPage_: {
type: String,
value: () =>
WindowProxy.getInstance().url.searchParams.get(CUSTOMIZE_URL_PARAM),
},
showVoiceSearchOverlay_: Boolean,
showBackgroundImage_: {
computed: 'computeShowBackgroundImage_(theme_)',
observer: 'onShowBackgroundImageChange_',
reflectToAttribute: true,
type: Boolean,
},
backgroundImageAttribution1_: {
type: String,
computed: `computeBackgroundImageAttribution1_(theme_)`,
},
backgroundImageAttribution2_: {
type: String,
computed: `computeBackgroundImageAttribution2_(theme_)`,
},
backgroundImageAttributionUrl_: {
type: String,
computed: `computeBackgroundImageAttributionUrl_(theme_)`,
},
backgroundColor_: {
computed: 'computeBackgroundColor_(showBackgroundImage_, theme_)',
type: Object,
},
logoColor_: {
type: String,
computed: 'computeLogoColor_(theme_)',
},
singleColoredLogo_: {
computed: 'computeSingleColoredLogo_(theme_)',
type: Boolean,
},
realboxShown_: {
type: Boolean,
computed: 'computeRealboxShown_(theme_)',
},
logoEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('logoEnabled'),
},
oneGoogleBarEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('oneGoogleBarEnabled'),
},
shortcutsEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('shortcutsEnabled'),
},
modulesRedesignedLayoutEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('modulesRedesignedLayoutEnabled'),
reflectToAttribute: true,
},
middleSlotPromoEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('middleSlotPromoEnabled'),
},
modulesEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('modulesEnabled'),
},
modulesRedesignedEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('modulesRedesignedEnabled'),
reflectToAttribute: true,
},
middleSlotPromoLoaded_: {
type: Boolean,
value: false,
},
modulesLoaded_: {
type: Boolean,
value: false,
},
/**
* In order to avoid flicker, the promo and modules are hidden until both
* are loaded. If modules are disabled, the promo is shown as soon as it
* is loaded.
*/
promoAndModulesLoaded_: {
type: Boolean,
computed: `computePromoAndModulesLoaded_(middleSlotPromoLoaded_,
modulesLoaded_)`,
observer: 'onPromoAndModulesLoadedChange_',
},
/**
* If true, renders additional elements that were not deemed crucial to
* to show up immediately on load.
*/
lazyRender_: Boolean,
};
}
private oneGoogleBarIframePath_: string;
private oneGoogleBarLoaded_: boolean;
private oneGoogleBarDarkThemeEnabled_: boolean;
private theme_: Theme;
private showCustomizeDialog_: boolean;
private selectedCustomizeDialogPage_: string|null;
private showVoiceSearchOverlay_: boolean;
private showBackgroundImage_: boolean;
private backgroundImageAttribution1_: string;
private backgroundImageAttribution2_: string;
private backgroundImageAttributionUrl_: string;
private backgroundColor_: SkColor;
private logoColor_: string;
private singleColoredLogo_: boolean;
private realboxShown_: boolean;
private logoEnabled_: boolean;
private oneGoogleBarEnabled_: boolean;
private shortcutsEnabled_: boolean;
private modulesRedesignedLayoutEnabled_: boolean;
private middleSlotPromoEnabled_: boolean;
private modulesEnabled_: boolean;
private modulesRedesignedEnabled_: boolean;
private middleSlotPromoLoaded_: boolean;
private modulesLoaded_: boolean;
private promoAndModulesLoaded_: boolean;
private lazyRender_: boolean;
private callbackRouter_: PageCallbackRouter;
private pageHandler_: PageHandlerRemote;
private backgroundManager_: BackgroundManager;
private setThemeListenerId_: number|null = null;
private eventTracker_: EventTracker = new EventTracker();
private shouldPrintPerformance_: boolean;
private backgroundImageLoadStartEpoch_: number;
private backgroundImageLoadStart_: number = 0;
// Suppress TypeScript's error TS2376 to intentionally allow calling
// performance.mark() before calling super().
// @ts-ignore:next-line
constructor() {
performance.mark('app-creation-start');
super();
this.callbackRouter_ = NewTabPageProxy.getInstance().callbackRouter;
this.pageHandler_ = NewTabPageProxy.getInstance().handler;
this.backgroundManager_ = BackgroundManager.getInstance();
this.shouldPrintPerformance_ =
new URLSearchParams(location.search).has('print_perf');
/**
* Initialized with the start of the performance timeline in case the
* background image load is not triggered by JS.
*/
this.backgroundImageLoadStartEpoch_ = performance.timeOrigin;
chrome.metricsPrivate.recordValue(
{
metricName: 'NewTabPage.Height',
type: chrome.metricsPrivate.MetricTypeType.HISTOGRAM_LINEAR,
min: 1,
max: 1000,
buckets: 200,
},
Math.floor(document.documentElement.clientHeight));
}
connectedCallback() {
super.connectedCallback();
this.setThemeListenerId_ =
this.callbackRouter_.setTheme.addListener((theme: Theme) => {
performance.measure('theme-set');
this.theme_ = theme;
});
this.eventTracker_.add(window, 'message', (event: MessageEvent) => {
const data = event.data;
// Something in OneGoogleBar is sending a message that is received here.
// Need to ignore it.
if (typeof data !== 'object') {
return;
}
if ('frameType' in data && data.frameType === 'one-google-bar') {
this.handleOneGoogleBarMessage_(event);
}
});
this.eventTracker_.add(window, 'keydown', this.onWindowKeydown_.bind(this));
this.eventTracker_.add(
window, 'click', this.onWindowClick_.bind(this), /*capture=*/ true);
if (this.shouldPrintPerformance_) {
// It is possible that the background image has already loaded by now.
// If it has, we request it to re-send the load time so that we can
// actually catch the load time.
this.backgroundManager_.getBackgroundImageLoadTime().then(
time => {
const duration = time - this.backgroundImageLoadStartEpoch_;
this.printPerformanceDatum_(
'background-image-load', this.backgroundImageLoadStart_,
duration);
this.printPerformanceDatum_(
'background-image-loaded',
this.backgroundImageLoadStart_ + duration);
},
() => {
console.error('Failed to capture background image load time');
});
}
FocusOutlineManager.forDocument(document);
}
disconnectedCallback() {
super.disconnectedCallback();
this.callbackRouter_.removeListener(this.setThemeListenerId_!);
this.eventTracker_.removeAll();
}
ready() {
super.ready();
this.pageHandler_.onAppRendered(WindowProxy.getInstance().now());
// Let the browser breath and then render remaining elements.
WindowProxy.getInstance().waitForLazyRender().then(() => {
ensureLazyLoaded();
this.lazyRender_ = true;
});
this.printPerformance_();
performance.measure('app-creation', 'app-creation-start');
}
private computeOneGoogleBarDarkThemeEnabled_(): boolean {
return this.theme_ && this.theme_.isDark;
}
private notifyOneGoogleBarDarkThemeEnabledChange_() {
if (this.oneGoogleBarLoaded_) {
$$<IframeElement>(this, '#oneGoogleBar')!.postMessage({
type: 'enableDarkTheme',
enabled: this.oneGoogleBarDarkThemeEnabled_,
});
}
}
private computeBackgroundImageAttribution1_(): string {
return this.theme_ && this.theme_.backgroundImageAttribution1 || '';
}
private computeBackgroundImageAttribution2_(): string {
return this.theme_ && this.theme_.backgroundImageAttribution2 || '';
}
private computeBackgroundImageAttributionUrl_(): string {
return this.theme_ && this.theme_.backgroundImageAttributionUrl ?
this.theme_.backgroundImageAttributionUrl.url :
'';
}
private computeRealboxShown_(): boolean {
// If realbox is to match the Omnibox's theme, keep it hidden until the
// theme arrives. Otherwise mismatching colors will cause flicker.
return !loadTimeData.getBoolean('realboxMatchOmniboxTheme') ||
!!this.theme_;
}
private computePromoAndModulesLoaded_(): boolean {
return (!loadTimeData.getBoolean('middleSlotPromoEnabled') ||
this.middleSlotPromoLoaded_) &&
(!loadTimeData.getBoolean('modulesEnabled') || this.modulesLoaded_);
}
private async onLazyRendered_() {
// Integration tests use this attribute to determine when lazy load has
// completed.
document.documentElement.setAttribute('lazy-loaded', String(true));
// Instantiate modules even if |modulesEnabled| is false to counterfactually
// trigger a HaTS survey in a potential control group.
if (!loadTimeData.getBoolean('modulesLoadEnabled') ||
loadTimeData.getBoolean('modulesEnabled')) {
return;
}
const modules = await ModuleRegistry.getInstance().initializeModules(
loadTimeData.getInteger('modulesLoadTimeout'));
if (modules) {
this.pageHandler_.onModulesLoadedWithData();
}
}
private onOpenVoiceSearch_() {
this.showVoiceSearchOverlay_ = true;
recordVoiceAction(VoiceAction.kActivateSearchBox);
}
private onCustomizeClick_() {
this.showCustomizeDialog_ = true;
}
private onCustomizeDialogClose_() {
this.showCustomizeDialog_ = false;
// Let customize dialog decide what page to show on next open.
this.selectedCustomizeDialogPage_ = null;
}
private onVoiceSearchOverlayClose_() {
this.showVoiceSearchOverlay_ = false;
}
/**
* Handles <CTRL> + <SHIFT> + <.> (also <CMD> + <SHIFT> + <.> on mac) to open
* voice search.
*/
private onWindowKeydown_(e: KeyboardEvent) {
let ctrlKeyPressed = e.ctrlKey;
// <if expr="is_macosx">
ctrlKeyPressed = ctrlKeyPressed || e.metaKey;
// </if>
if (ctrlKeyPressed && e.code === 'Period' && e.shiftKey) {
this.showVoiceSearchOverlay_ = true;
recordVoiceAction(VoiceAction.kActivateKeyboard);
}
}
private rgbaOrInherit_(skColor: SkColor|null): string {
return skColor ? skColorToRgba(skColor) : 'inherit';
}
private computeShowBackgroundImage_(): boolean {
return !!this.theme_ && !!this.theme_.backgroundImage;
}
private onShowBackgroundImageChange_() {
this.backgroundManager_.setShowBackgroundImage(this.showBackgroundImage_);
}
private onThemeChange_() {
if (this.theme_) {
this.backgroundManager_.setBackgroundColor(this.theme_.backgroundColor);
}
this.updateBackgroundImagePath_();
}
private onPromoAndModulesLoadedChange_() {
if (this.promoAndModulesLoaded_ &&
loadTimeData.getBoolean('modulesEnabled')) {
recordLoadDuration(
'NewTabPage.Modules.ShownTime', WindowProxy.getInstance().now());
}
}
/**
* Set the #backgroundImage |path| only when different and non-empty. Reset
* the customize dialog background selection if the dialog is closed.
*
* The ntp-untrusted-iframe |path| is set directly. When using a data binding
* instead, the quick updates to the |path| result in iframe loading an error
* page.
*/
private updateBackgroundImagePath_() {
const backgroundImage = this.theme_ && this.theme_.backgroundImage;
if (backgroundImage) {
this.backgroundManager_.setBackgroundImage(backgroundImage);
}
}
private computeBackgroundColor_(): SkColor|null {
if (this.showBackgroundImage_) {
return null;
}
return this.theme_ && this.theme_.backgroundColor;
}
private computeLogoColor_(): SkColor|null {
return this.theme_ &&
(this.theme_.logoColor ||
(this.theme_.isDark ? hexColorToSkColor('#ffffff') : null));
}
private computeSingleColoredLogo_(): boolean {
return this.theme_ && (!!this.theme_.logoColor || this.theme_.isDark);
}
/**
* Sends the command received from the given source and origin to the browser.
* Relays the browser response to whether or not a promo containing the given
* command can be shown back to the source promo frame. |commandSource| and
* |commandOrigin| are used only to send the response back to the source promo
* frame and should not be used for anything else.
* @param messageData Data received from the source promo frame.
* @param commandSource Source promo frame.
* @param commandOrigin Origin of the source promo frame.
*/
private canShowPromoWithBrowserCommand_(
messageData: CanShowPromoWithBrowserCommandData, commandSource: Window,
commandOrigin: string) {
// Make sure we don't send unsupported commands to the browser.
/** @type {!Command} */
const commandId = Object.values(Command).includes(messageData.commandId) ?
messageData.commandId :
Command.kUnknownCommand;
BrowserCommandProxy.getInstance().handler.canExecuteCommand(commandId).then(
({canExecute}) => {
const response = {
messageType: messageData.messageType,
[messageData.commandId]: canExecute,
};
commandSource.postMessage(response, commandOrigin);
});
}
/**
* Sends the command and the accompanying mouse click info received from the
* promo of the given source and origin to the browser. Relays the execution
* status response back to the source promo frame. |commandSource| and
* |commandOrigin| are used only to send the execution status response back to
* the source promo frame and should not be used for anything else.
* @param commandData Command and mouse click info.
* @param commandSource Source promo frame.
* @param commandOrigin Origin of the source promo frame.
*/
private executePromoBrowserCommand_(
commandData: ExecutePromoBrowserCommandData, commandSource: Window,
commandOrigin: string) {
// Make sure we don't send unsupported commands to the browser.
const commandId = Object.values(Command).includes(commandData.commandId) ?
commandData.commandId :
Command.kUnknownCommand;
BrowserCommandProxy.getInstance()
.handler.executeCommand(commandId, commandData.clickInfo)
.then(({commandExecuted}) => {
commandSource.postMessage(commandExecuted, commandOrigin);
});
}
/**
* Handles messages from the OneGoogleBar iframe. The messages that are
* handled include show bar on load and overlay updates.
*
* 'overlaysUpdated' message includes the updated array of overlay rects that
* are shown.
*/
private handleOneGoogleBarMessage_(event: MessageEvent) {
const data = event.data;
if (data.messageType === 'loaded') {
const oneGoogleBar = $$<IframeElement>(this, '#oneGoogleBar')!;
oneGoogleBar.style.clipPath = 'url(#oneGoogleBarClipPath)';
oneGoogleBar.style.zIndex = '1000';
this.oneGoogleBarLoaded_ = true;
this.pageHandler_.onOneGoogleBarRendered(WindowProxy.getInstance().now());
} else if (data.messageType === 'overlaysUpdated') {
this.$.oneGoogleBarClipPath.querySelectorAll('rect').forEach(el => {
el.remove();
});
const overlayRects = data.data as DOMRect[];
overlayRects.forEach(({x, y, width, height}) => {
const rectElement =
document.createElementNS('http://www.w3.org/2000/svg', 'rect');
// Add 8px around every rect to ensure shadows are not cutoff.
rectElement.setAttribute('x', `${x - 8}`);
rectElement.setAttribute('y', `${y - 8}`);
rectElement.setAttribute('width', `${width + 16}`);
rectElement.setAttribute('height', `${height + 16}`);
this.$.oneGoogleBarClipPath.appendChild(rectElement);
});
} else if (data.messageType === 'can-show-promo-with-browser-command') {
this.canShowPromoWithBrowserCommand_(
data, event.source as Window, event.origin);
} else if (data.messageType === 'execute-browser-command') {
this.executePromoBrowserCommand_(
data.data, event.source as Window, event.origin);
} else if (data.messageType === 'click') {
recordClick(NtpElement.kOneGoogleBar);
}
}
private onMiddleSlotPromoLoaded_() {
this.middleSlotPromoLoaded_ = true;
}
private onModulesLoaded_() {
this.modulesLoaded_ = true;
}
private onCustomizeModule_() {
this.showCustomizeDialog_ = true;
this.selectedCustomizeDialogPage_ = CustomizeDialogPage.MODULES;
}
private printPerformanceDatum_(
name: string, time: number, auxTime: number = 0) {
if (!this.shouldPrintPerformance_) {
return;
}
if (!auxTime) {
console.log(`${name}: ${time}`);
} else {
console.log(`${name}: ${time} (${auxTime})`);
}
}
/**
* Prints performance measurements to the console. Also, installs performance
* observer to continuously print performance measurements after.
*/
private printPerformance_() {
if (!this.shouldPrintPerformance_) {
return;
}
const entryTypes = ['paint', 'measure'];
const log = (entry: PerformanceEntry) => {
this.printPerformanceDatum_(
entry.name, entry.duration ? entry.duration : entry.startTime,
entry.duration && entry.startTime ? entry.startTime : 0);
};
const observer = new PerformanceObserver(list => {
list.getEntries().forEach((entry) => {
log(entry);
});
});
observer.observe({entryTypes: entryTypes});
performance.getEntries().forEach((entry) => {
if (!entryTypes.includes(entry.entryType)) {
return;
}
log(entry);
});
}
private onWindowClick_(e: Event) {
if (e.composedPath() && e.composedPath()[0] === $$(this, '#content')) {
recordClick(NtpElement.kBackground);
return;
}
for (const target of e.composedPath()) {
switch (target) {
case $$(this, 'ntp-logo'):
recordClick(NtpElement.kLogo);
return;
case $$(this, 'ntp-realbox'):
recordClick(NtpElement.kRealbox);
return;
case $$(this, 'cr-most-visited'):
recordClick(NtpElement.kMostVisited);
return;
case $$(this, 'ntp-middle-slot-promo'):
recordClick(NtpElement.kMiddleSlotPromo);
return;
case $$(this, 'ntp-modules'):
recordClick(NtpElement.kModule);
return;
case $$(this, '#customizeButton'):
case $$(this, 'ntp-customize-dialog'):
recordClick(NtpElement.kCustomize);
return;
}
}
recordClick(NtpElement.kOther);
}
}
declare global {
interface HTMLElementTagNameMap {
'ntp-app': AppElement;
}
}
customElements.define(AppElement.is, AppElement);