blob: af519b04d0505624ec75f4e0f5e0faddef9844d1 [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 {assert} from 'chrome://resources/js/assert.m.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 {html, mixinBehaviors, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {BackgroundManager} from './background_manager.js';
import {BackgroundSelection, BackgroundSelectionType, CustomizeDialogPage} from './customize_dialog_types.js';
import {I18nBehavior, loadTimeData} from './i18n_setup.js';
import {recordLoadDuration} from './metrics_utils.js';
import {ModuleRegistry} from './modules/module_registry.js';
import {NewTabPageProxy} from './new_tab_page_proxy.js';
import {PromoBrowserCommandProxy} from './promo_browser_command_proxy.js';
import {$$} from './utils.js';
import {Action as VoiceAction, recordVoiceAction} from './voice_search_overlay.js';
import {WindowProxy} from './window_proxy.js';
/**
* @typedef {{
* commandId: promoBrowserCommand.mojom.Command<number>,
* clickInfo: !promoBrowserCommand.mojom.ClickInfo
* }}
*/
let CommandData;
/**
* 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.
* @enum {number}
*/
export const NtpElement = {
kOther: 0,
kBackground: 1,
kOneGoogleBar: 2,
kLogo: 3,
kRealbox: 4,
kMostVisited: 5,
kMiddleSlotPromo: 6,
kModule: 7,
kCustomize: 8,
};
/** @param {NtpElement} element */
function recordClick(element) {
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);
}
/**
* @polymer
* @extends {PolymerElement}
*/
class AppElement extends mixinBehaviors
([I18nBehavior], PolymerElement) {
static get is() {
return 'ntp-app';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
/** @private */
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}`;
},
},
/** @private */
oneGoogleBarLoaded_: {
type: Boolean,
observer: 'notifyOneGoogleBarDarkThemeEnabledChange_',
},
/** @private */
oneGoogleBarDarkThemeEnabled_: {
type: Boolean,
computed: `computeOneGoogleBarDarkThemeEnabled_(oneGoogleBarLoaded_,
theme_, backgroundSelection_)`,
observer: 'notifyOneGoogleBarDarkThemeEnabledChange_',
},
/** @private {!newTabPage.mojom.Theme} */
theme_: {
observer: 'onThemeChange_',
type: Object,
},
/** @private */
showCustomizeDialog_: Boolean,
/** @private {?string} */
selectedCustomizeDialogPage_: String,
/** @private */
showVoiceSearchOverlay_: Boolean,
/** @private */
showBackgroundImage_: {
computed: 'computeShowBackgroundImage_(theme_, backgroundSelection_)',
observer: 'onShowBackgroundImageChange_',
reflectToAttribute: true,
type: Boolean,
},
/** @private {!BackgroundSelection} */
backgroundSelection_: {
type: Object,
value: () => ({type: BackgroundSelectionType.NO_SELECTION}),
observer: 'updateBackgroundImagePath_',
},
/** @private */
backgroundImageAttribution1_: {
type: String,
computed: `computeBackgroundImageAttribution1_(theme_,
backgroundSelection_)`,
},
/** @private */
backgroundImageAttribution2_: {
type: String,
computed: `computeBackgroundImageAttribution2_(theme_,
backgroundSelection_)`,
},
/** @private */
backgroundImageAttributionUrl_: {
type: String,
computed: `computeBackgroundImageAttributionUrl_(theme_,
backgroundSelection_)`,
},
/** @private {SkColor} */
backgroundColor_: {
computed: 'computeBackgroundColor_(showBackgroundImage_, theme_)',
type: Object,
},
/** @private */
logoColor_: {
type: String,
computed: 'computeLogoColor_(theme_, backgroundSelection_)',
},
/** @private */
singleColoredLogo_: {
computed: 'computeSingleColoredLogo_(theme_, backgroundSelection_)',
type: Boolean,
},
/** @private */
realboxShown_: {
type: Boolean,
computed: 'computeRealboxShown_(theme_)',
},
/** @private */
logoEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('logoEnabled'),
},
/** @private */
shortcutsEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('shortcutsEnabled'),
},
/** @private */
middleSlotPromoEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('middleSlotPromoEnabled'),
},
/** @private */
modulesEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('modulesEnabled'),
},
/** @private */
middleSlotPromoLoaded_: {
type: Boolean,
value: false,
},
/** @private */
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.
* @private
*/
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.
* @private
*/
lazyRender_: Boolean,
};
}
constructor() {
performance.mark('app-creation-start');
super();
/** @private {!newTabPage.mojom.PageCallbackRouter} */
this.callbackRouter_ = NewTabPageProxy.getInstance().callbackRouter;
/** @private {newTabPage.mojom.PageHandlerRemote} */
this.pageHandler_ = NewTabPageProxy.getInstance().handler;
/** @private {!BackgroundManager} */
this.backgroundManager_ = BackgroundManager.getInstance();
/** @private {?number} */
this.setThemeListenerId_ = null;
/** @private {!EventTracker} */
this.eventTracker_ = new EventTracker();
/** @private {boolean} */
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.
* @private {number}
*/
this.backgroundImageLoadStartEpoch_ = performance.timeOrigin;
/** @private {number} */
this.backgroundImageLoadStart_ = 0;
}
/** @override */
connectedCallback() {
super.connectedCallback();
this.setThemeListenerId_ =
this.callbackRouter_.setTheme.addListener(theme => {
performance.measure('theme-set');
this.theme_ = theme;
});
this.eventTracker_.add(window, 'message', (event) => {
/** @type {!Object} */
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', e => this.onWindowKeydown_(e));
this.eventTracker_.add(
window, 'click', e => this.onWindowClick_(e), /*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);
}
/** @override */
disconnectedCallback() {
super.disconnectedCallback();
this.callbackRouter_.removeListener(assert(this.setThemeListenerId_));
this.eventTracker_.removeAll();
}
/** @override */
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');
}
/**
* @return {boolean}
* @private
*/
computeOneGoogleBarDarkThemeEnabled_() {
switch (this.backgroundSelection_.type) {
case BackgroundSelectionType.IMAGE:
return true;
case BackgroundSelectionType.NO_BACKGROUND:
case BackgroundSelectionType.DAILY_REFRESH:
case BackgroundSelectionType.NO_SELECTION:
default:
return this.theme_ && this.theme_.isDark;
}
}
/** @private */
notifyOneGoogleBarDarkThemeEnabledChange_() {
if (this.oneGoogleBarLoaded_) {
$$(this, '#oneGoogleBar').postMessage({
type: 'enableDarkTheme',
enabled: this.oneGoogleBarDarkThemeEnabled_,
});
}
}
/**
* @return {string}
* @private
*/
computeBackgroundImageAttribution1_() {
switch (this.backgroundSelection_.type) {
case BackgroundSelectionType.NO_SELECTION:
return this.theme_ && this.theme_.backgroundImageAttribution1 || '';
case BackgroundSelectionType.IMAGE:
return this.backgroundSelection_.image.attribution1;
case BackgroundSelectionType.NO_BACKGROUND:
case BackgroundSelectionType.DAILY_REFRESH:
default:
return '';
}
}
/**
* @return {string}
* @private
*/
computeBackgroundImageAttribution2_() {
switch (this.backgroundSelection_.type) {
case BackgroundSelectionType.NO_SELECTION:
return this.theme_ && this.theme_.backgroundImageAttribution2 || '';
case BackgroundSelectionType.IMAGE:
return this.backgroundSelection_.image.attribution2;
case BackgroundSelectionType.NO_BACKGROUND:
case BackgroundSelectionType.DAILY_REFRESH:
default:
return '';
}
}
/**
* @return {string}
* @private
*/
computeBackgroundImageAttributionUrl_() {
switch (this.backgroundSelection_.type) {
case BackgroundSelectionType.NO_SELECTION:
return this.theme_ && this.theme_.backgroundImageAttributionUrl ?
this.theme_.backgroundImageAttributionUrl.url :
'';
case BackgroundSelectionType.IMAGE:
return this.backgroundSelection_.image.attributionUrl.url;
case BackgroundSelectionType.NO_BACKGROUND:
case BackgroundSelectionType.DAILY_REFRESH:
default:
return '';
}
}
/**
* @return {boolean}
* @private
*/
computeRealboxShown_() {
// 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_;
}
/**
* @return {boolean}
* @private
*/
computePromoAndModulesLoaded_() {
return (!loadTimeData.getBoolean('middleSlotPromoEnabled') ||
this.middleSlotPromoLoaded_) &&
(!loadTimeData.getBoolean('modulesEnabled') || this.modulesLoaded_);
}
/** @private */
async onLazyRendered_() {
// 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.
* @param {KeyboardEvent} e
* @private
*/
onWindowKeydown_(e) {
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);
}
}
/**
* @param {SkColor} skColor
* @return {string}
* @private
*/
rgbaOrInherit_(skColor) {
return skColor ? skColorToRgba(skColor) : 'inherit';
}
/**
* @return {boolean}
* @private
*/
computeShowBackgroundImage_() {
switch (this.backgroundSelection_.type) {
case BackgroundSelectionType.NO_SELECTION:
return !!this.theme_ && !!this.theme_.backgroundImage;
case BackgroundSelectionType.IMAGE:
return true;
case BackgroundSelectionType.NO_BACKGROUND:
case BackgroundSelectionType.DAILY_REFRESH:
default:
return false;
}
}
/** @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_) {
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_() {
// The |backgroundSelection_| is retained after the dialog commits the
// change to the theme. Since |backgroundSelection_| has precedence over
// the theme background, the |backgroundSelection_| needs to be reset when
// the theme is updated. This is only necessary when the dialog is closed.
// If the dialog is open, it will either commit the |backgroundSelection_|
// or reset |backgroundSelection_| on cancel.
//
// Update after background image path is updated so the image is not shown
// before the path is updated.
if (!this.showCustomizeDialog_ &&
this.backgroundSelection_.type !==
BackgroundSelectionType.NO_SELECTION) {
// Wait when local image is selected, then no background is previewed
// followed by selecting a new local image. This avoids a flicker. The
// iframe with the old image is shown briefly before it navigates to a new
// iframe location, then fetches and renders the new local image.
if (this.backgroundSelection_.type ===
BackgroundSelectionType.NO_BACKGROUND) {
setTimeout(() => {
this.backgroundSelection_ = {
type: BackgroundSelectionType.NO_SELECTION
};
}, 100);
} else {
this.backgroundSelection_ = {
type: BackgroundSelectionType.NO_SELECTION
};
}
}
/** @type {newTabPage.mojom.BackgroundImage|undefined} */
let backgroundImage;
switch (this.backgroundSelection_.type) {
case BackgroundSelectionType.NO_SELECTION:
backgroundImage = this.theme_ && this.theme_.backgroundImage;
break;
case BackgroundSelectionType.IMAGE:
backgroundImage = {
url: {url: this.backgroundSelection_.image.imageUrl.url}
};
break;
}
if (backgroundImage) {
this.backgroundManager_.setBackgroundImage(backgroundImage);
}
}
/**
* @return {SkColor}
* @private
*/
computeBackgroundColor_() {
if (this.showBackgroundImage_) {
return null;
}
return this.theme_ && this.theme_.backgroundColor;
}
/**
* @return {SkColor}
* @private
*/
computeLogoColor_() {
switch (this.backgroundSelection_.type) {
case BackgroundSelectionType.IMAGE:
return hexColorToSkColor('#ffffff');
case BackgroundSelectionType.NO_SELECTION:
case BackgroundSelectionType.NO_BACKGROUND:
case BackgroundSelectionType.DAILY_REFRESH:
default:
return this.theme_ &&
(this.theme_.logoColor ||
(this.theme_.isDark ? hexColorToSkColor('#ffffff') : null));
}
}
/**
* @return {boolean}
* @private
*/
computeSingleColoredLogo_() {
switch (this.backgroundSelection_.type) {
case BackgroundSelectionType.IMAGE:
return true;
case BackgroundSelectionType.DAILY_REFRESH:
case BackgroundSelectionType.NO_BACKGROUND:
case BackgroundSelectionType.NO_SELECTION:
default:
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 {Object} messageData Data received from the source promo frame.
* @param {Window} commandSource Source promo frame.
* @param {string} commandOrigin Origin of the source promo frame.
* @private
*/
canShowPromoWithBrowserCommand_(messageData, commandSource, commandOrigin) {
// Make sure we don't send unsupported commands to the browser.
/** @type {!promoBrowserCommand.mojom.Command} */
const commandId = Object.values(promoBrowserCommand.mojom.Command)
.includes(messageData.commandId) ?
messageData.commandId :
promoBrowserCommand.mojom.Command.kUnknownCommand;
PromoBrowserCommandProxy.getInstance()
.handler.canShowPromoWithCommand(commandId)
.then(({canShow}) => {
const response = {messageType: messageData.messageType};
response[messageData.commandId] = canShow;
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} commandData Command and mouse click info.
* @param {Window} commandSource Source promo frame.
* @param {string} commandOrigin Origin of the source promo frame.
* @private
*/
executePromoBrowserCommand_(commandData, commandSource, commandOrigin) {
// Make sure we don't send unsupported commands to the browser.
/** @type {!promoBrowserCommand.mojom.Command} */
const commandId = Object.values(promoBrowserCommand.mojom.Command)
.includes(commandData.commandId) ?
commandData.commandId :
promoBrowserCommand.mojom.Command.kUnknownCommand;
PromoBrowserCommandProxy.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.
* @param {!MessageEvent} event
* @private
*/
handleOneGoogleBarMessage_(event) {
/** @type {!Object} */
const data = event.data;
if (data.messageType === 'loaded') {
const oneGoogleBar = $$(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 = /** @type {!Array<!DOMRect>} */ (data.data);
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, event.origin);
} else if (data.messageType === 'execute-browser-command') {
this.executePromoBrowserCommand_(
/** @type {!CommandData} */ (data.data), event.source, 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, time, auxTime = 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) => {
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);
});
}
/**
* @param {Event} e
* @private
*/
onWindowClick_(e) {
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, 'ntp-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);
}
}
customElements.define(AppElement.is, AppElement);