// 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 'chrome://resources/cr_elements/cr_hidden_style.css.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import type {HelpBubbleMixinInterface} from 'chrome://resources/cr_components/help_bubble/help_bubble_mixin.js';
import {HelpBubbleMixin} from 'chrome://resources/cr_components/help_bubble/help_bubble_mixin.js';
import type {CrToastElement} from 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import type {TemplateInstanceBase} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PolymerElement, templatize} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {loadTimeData} from '../../i18n_setup.js';
import {recordOccurence as recordOccurrence} from '../../metrics_utils.js';
import {IphFeature} from '../../new_tab_page.mojom-webui.js';
import {NewTabPageProxy} from '../../new_tab_page_proxy.js';
import {WindowProxy} from '../../window_proxy.js';
import type {Module} from '../module_descriptor.js';
import {ModuleRegistry} from '../module_registry.js';
import type {ModuleInstance, ModuleWrapperElement} from '../module_wrapper.js';
import {getTemplate} from './modules.html.js';
export interface NamedWidth {
name: string;
value: number;
}
export const SUPPORTED_MODULE_WIDTHS: NamedWidth[] = [
{name: 'narrow', value: 312},
{name: 'medium', value: 360},
{name: 'wide', value: 728},
];
interface QueryDetails {
maxWidth: number;
query: string;
}
const CONTAINER_GAP_WIDTH = 8;
const MARGIN_WIDTH = 48;
const METRIC_NAME_MODULE_DISABLED = 'NewTabPage.Modules.Disabled';
export type UndoActionEvent =
CustomEvent<{message: string, restoreCallback?: () => void}>;
export type DismissModuleElementEvent = UndoActionEvent;
export type DismissModuleInstanceEvent = UndoActionEvent;
export type DisableModuleEvent = UndoActionEvent;
declare global {
interface HTMLElementEventMap {
'disable-module': DisableModuleEvent;
'dismiss-module-instance': DismissModuleInstanceEvent;
'dismiss-module-element': DismissModuleElementEvent;
}
}
export interface ModulesV2Element {
$: {
container: HTMLElement,
undoToast: CrToastElement,
undoToastMessage: HTMLElement,
};
}
export const MODULE_CUSTOMIZE_ELEMENT_ID =
'NewTabPageUI::kModulesCustomizeIPHAnchorElement';
const AppElementBase = HelpBubbleMixin(PolymerElement) as
{new (): PolymerElement & HelpBubbleMixinInterface};
/** Container for the NTP modules. */
export class ModulesV2Element extends AppElementBase {
static get is() {
return 'ntp-modules-v2';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
disabledModules_: {
type: Object,
observer: 'onDisabledModulesChange_',
value: () => ({all: true, ids: []}),
},
modulesShownToUser: {
type: Boolean,
notify: true,
},
/** Data about the most recent un-doable action. */
undoData_: {
type: Object,
value: null,
},
};
}
modulesShownToUser: boolean;
private maxColumnCount_: number;
private containerMaxWidth_: number;
private disabledModules_: {all: boolean, ids: string[]};
private eventTracker_: EventTracker = new EventTracker();
private undoData_: {message: string, undo?: () => void}|null;
private setDisabledModulesListenerId_: number|null = null;
private containerObserver_: MutationObserver|null = null;
private templateInstances_: TemplateInstanceBase[] = [];
override connectedCallback() {
super.connectedCallback();
this.setDisabledModulesListenerId_ =
NewTabPageProxy.getInstance()
.callbackRouter.setDisabledModules.addListener(
(all: boolean, ids: string[]) => {
this.disabledModules_ = {all, ids};
});
NewTabPageProxy.getInstance().handler.updateDisabledModules();
const widths: Set<number> = new Set();
for (let i = 0; i < SUPPORTED_MODULE_WIDTHS.length; i++) {
const namedWidth = SUPPORTED_MODULE_WIDTHS[i];
for (let u = 1; u <= this.maxColumnCount_ - i; u++) {
const width = (namedWidth.value * u) + (CONTAINER_GAP_WIDTH * (u - 1));
if (width <= this.containerMaxWidth_) {
widths.add(width);
}
}
}
// Widths must be deduped and sorted to ensure the min-width and max-with
// media features in the queries produced below are correctly generated.
const thresholds = [...widths];
thresholds.sort((i, j) => i - j);
const queries: QueryDetails[] = [];
for (let i = 1; i < thresholds.length - 1; i++) {
queries.push({
maxWidth: (thresholds[i + 1] - 1),
query: `(min-width: ${
thresholds[i] + 2 * MARGIN_WIDTH}px) and (max-width: ${
thresholds[i + 1] - 1 + (2 * MARGIN_WIDTH)}px)`,
});
}
queries.splice(0, 0, {
maxWidth: thresholds[0],
query: `(max-width: ${thresholds[0] - 1 + (2 * MARGIN_WIDTH)}px)`,
});
queries.push({
maxWidth: thresholds[thresholds.length - 1],
query: `(min-width: ${
thresholds[thresholds.length - 1] + (2 * MARGIN_WIDTH)}px)`,
});
// Produce media queries with relevant view thresholds at which module
// instance optimal widths should be re-evaluated.
queries.forEach(details => {
const query = WindowProxy.getInstance().matchMedia(details.query);
this.eventTracker_.add(query, 'change', (e: MediaQueryListEvent) => {
if (e.matches) {
this.updateContainerAndChildrenStyles_(details.maxWidth);
}
});
});
this.eventTracker_.add(window, 'keydown', this.onWindowKeydown_.bind(this));
this.containerObserver_ = new MutationObserver(() => {
this.updateContainerAndChildrenStyles_();
});
this.containerObserver_!.observe(this.$.container, {childList: true});
}
override disconnectedCallback() {
super.disconnectedCallback();
assert(this.setDisabledModulesListenerId_);
NewTabPageProxy.getInstance().callbackRouter.removeListener(
this.setDisabledModulesListenerId_);
this.eventTracker_.removeAll();
this.containerObserver_!.disconnect();
}
override ready() {
super.ready();
this.updateStyles({
'--container-gap': `${CONTAINER_GAP_WIDTH}px`,
});
this.maxColumnCount_ = loadTimeData.getInteger('modulesMaxColumnCount');
this.containerMaxWidth_ =
this.maxColumnCount_ * SUPPORTED_MODULE_WIDTHS[0].value +
(this.maxColumnCount_ - 1) * CONTAINER_GAP_WIDTH;
this.loadModules_();
}
private moduleDisabled_(
disabledModules: {all: true, ids: string[]},
instance: ModuleInstance): boolean {
return disabledModules.all ||
disabledModules.ids.includes(instance.descriptor.id);
}
private async loadModules_(): Promise<void> {
const modulesIdNames =
(await NewTabPageProxy.getInstance().handler.getModulesIdNames()).data;
const modules =
await ModuleRegistry.getInstance().initializeModulesHavingIds(
modulesIdNames.map(m => m.id),
loadTimeData.getInteger('modulesLoadTimeout'));
if (modules) {
NewTabPageProxy.getInstance().handler.onModulesLoadedWithData(
modules.map(module => module.descriptor.id));
const template = this.shadowRoot!.querySelector('template')!;
const moduleWrapperConstructor:
{new (_: Object): TemplateInstanceBase&HTMLElement} =
templatize(template, this, {
parentModel: true,
forwardHostProp: this.forwardHostProp_,
instanceProps: {item: true},
}) as {new (): TemplateInstanceBase & HTMLElement};
if (modules.length > 1) {
const maxModuleInstanceCount =
(modules.length >= this.maxColumnCount_) ?
1 :
loadTimeData.getInteger(
'multipleLoadedModulesMaxModuleInstanceCount');
if (maxModuleInstanceCount > 0) {
modules.forEach(module => {
module.elements.splice(
maxModuleInstanceCount,
module.elements.length - maxModuleInstanceCount);
});
}
}
this.templateInstances_ =
modules
.map(module => {
return module.elements.map(element => {
return {
element,
descriptor: module.descriptor,
};
});
})
.flat()
.map(instance => {
return new moduleWrapperConstructor({item: instance});
});
this.templateInstances_.map(t => t.children[0] as HTMLElement)
.forEach(wrapperElement => {
this.$.container.appendChild(wrapperElement);
});
chrome.metricsPrivate.recordSmallCount(
'NewTabPage.Modules.LoadedModulesCount', modules.length);
modulesIdNames.forEach(({id}) => {
chrome.metricsPrivate.recordBoolean(
`NewTabPage.Modules.EnabledOnNTPLoad.${id}`,
!this.disabledModules_.all &&
!this.disabledModules_.ids.includes(id));
});
chrome.metricsPrivate.recordSmallCount(
'NewTabPage.Modules.InstanceCount', this.templateInstances_.length);
chrome.metricsPrivate.recordBoolean(
'NewTabPage.Modules.VisibleOnNTPLoad', !this.disabledModules_.all);
this.recordModuleLoadedWithModules_(modules);
this.dispatchEvent(new Event('modules-loaded'));
if (this.templateInstances_.length > 0) {
this.registerHelpBubble(
MODULE_CUSTOMIZE_ELEMENT_ID,
[
'#container',
'ntp-module-wrapper',
'#moduleElement',
],
{fixed: true});
// TODO(crbug.com/40075330): Currently, a period of time must elapse
// between the registration of the anchor element and the promo
// invocation, else the anchor element will not be ready for use.
setTimeout(() => {
NewTabPageProxy.getInstance().handler.maybeShowFeaturePromo(
IphFeature.kCustomizeModules);
}, 1000);
}
}
}
private recordModuleLoadedWithModules_(modules: Module[]) {
const moduleDescriptorIds = modules.map(m => m.descriptor.id);
for (const moduleDescriptorId of moduleDescriptorIds) {
moduleDescriptorIds.forEach(id => {
if (id !== moduleDescriptorId) {
chrome.metricsPrivate.recordSparseValueWithPersistentHash(
`NewTabPage.Modules.LoadedWith.${moduleDescriptorId}`, id);
}
});
}
}
private forwardHostProp_(property: string, value: any) {
this.templateInstances_.forEach(instance => {
instance.forwardHostProp(property, value);
});
}
private updateContainerAndChildrenStyles_(availableWidth?: number) {
if (typeof availableWidth === 'undefined') {
availableWidth = Math.min(
document.body.clientWidth - 2 * MARGIN_WIDTH,
this.containerMaxWidth_);
}
const moduleWrappers =
Array.from(this.shadowRoot!.querySelectorAll(
'ntp-module-wrapper:not([hidden])')) as ModuleWrapperElement[];
this.modulesShownToUser = moduleWrappers.length !== 0;
if (moduleWrappers.length === 0) {
return;
}
this.updateStyles({'--container-max-width': `${availableWidth}px`});
const clamp = (min: number, val: number, max: number) =>
Math.max(min, Math.min(val, max));
const rowMaxInstanceCount = clamp(
1,
Math.floor(
(availableWidth + CONTAINER_GAP_WIDTH) /
(CONTAINER_GAP_WIDTH + SUPPORTED_MODULE_WIDTHS[0].value)),
this.maxColumnCount_);
let index = 0;
while (index < moduleWrappers.length) {
const instances = moduleWrappers.slice(index, index + rowMaxInstanceCount)
.map(w => w.module);
let namedWidth = SUPPORTED_MODULE_WIDTHS[0];
for (let i = 1; i < SUPPORTED_MODULE_WIDTHS.length; i++) {
if (Math.floor(
(availableWidth -
(CONTAINER_GAP_WIDTH * (instances.length - 1))) /
SUPPORTED_MODULE_WIDTHS[i].value) < instances.length) {
break;
}
namedWidth = SUPPORTED_MODULE_WIDTHS[i];
}
instances.slice(0, instances.length).forEach(instance => {
// The `format` attribute is leveraged by modules whose layout should
// change based on the available width.
instance.element.setAttribute('format', namedWidth.name);
instance.element.style.width = `${namedWidth.value}px`;
});
index += instances.length;
}
}
private onDisableModule_(e: DisableModuleEvent) {
const id = (e.target! as ModuleWrapperElement).module.descriptor.id;
const restoreCallback = e.detail.restoreCallback;
this.undoData_ = {
message: e.detail.message,
undo: () => {
if (restoreCallback) {
restoreCallback();
}
NewTabPageProxy.getInstance().handler.setModuleDisabled(id, false);
chrome.metricsPrivate.recordSparseValueWithPersistentHash(
'NewTabPage.Modules.Enabled', id);
chrome.metricsPrivate.recordSparseValueWithPersistentHash(
'NewTabPage.Modules.Enabled.Toast', id);
},
};
NewTabPageProxy.getInstance().handler.setModuleDisabled(id, true);
this.$.undoToast.show();
chrome.metricsPrivate.recordSparseValueWithPersistentHash(
METRIC_NAME_MODULE_DISABLED, id);
chrome.metricsPrivate.recordSparseValueWithPersistentHash(
`${METRIC_NAME_MODULE_DISABLED}.ModuleRequest`, id);
}
private onDisabledModulesChange_() {
this.updateContainerAndChildrenStyles_();
}
/**
* @param e Event notifying a module instance was dismissed. Contains the
* message to show in the toast.
*/
private onDismissModuleInstance_(e: DismissModuleInstanceEvent) {
const wrapper = (e.target! as ModuleWrapperElement);
const index = Array.from(wrapper.parentNode!.children).indexOf(wrapper);
wrapper.remove();
const restoreCallback = e.detail.restoreCallback;
this.undoData_ = {
message: e.detail.message,
undo: restoreCallback ?
() => {
this.$.container.insertBefore(
wrapper, this.$.container.childNodes[index]);
restoreCallback();
recordOccurrence('NewTabPage.Modules.Restored');
recordOccurrence(
`NewTabPage.Modules.Restored.${wrapper.module.descriptor.id}`);
} :
undefined,
};
// Notify the user.
this.$.undoToast.show();
NewTabPageProxy.getInstance().handler.onDismissModule(
wrapper.module.descriptor.id);
}
private onDismissModuleElement_(e: DismissModuleElementEvent) {
const restoreCallback = e.detail.restoreCallback;
this.undoData_ = {
message: e.detail.message,
undo: restoreCallback ?
() => {
restoreCallback();
} :
undefined,
};
// Notify the user.
this.$.undoToast.show();
}
private onUndoButtonClick_() {
if (!this.undoData_) {
return;
}
// Restore to the previous state.
this.undoData_.undo!();
// Notify the user.
this.$.undoToast.hide();
this.undoData_ = null;
}
private onWindowKeydown_(e: KeyboardEvent) {
let ctrlKeyPressed = e.ctrlKey;
// <if expr="is_macosx">
ctrlKeyPressed = ctrlKeyPressed || e.metaKey;
// </if>
if (ctrlKeyPressed && e.key === 'z') {
this.onUndoButtonClick_();
}
}
}
customElements.define(ModulesV2Element.is, ModulesV2Element);