chromium/chrome/browser/resources/new_tab_page/modules/modules.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 '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 {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 {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {loadTimeData} from '../i18n_setup.js';
import type {ModuleIdName} from '../new_tab_page.mojom-webui.js';
import {NewTabPageProxy} from '../new_tab_page_proxy.js';

import type {Module} from './module_descriptor.js';
import {ModuleRegistry} from './module_registry.js';
import {ModuleWrapperElement} from './module_wrapper.js';
import {getTemplate} from './modules.html.js';

export type DismissModuleEvent =
    CustomEvent<{message: string, restoreCallback?: () => void}>;
export type DisableModuleEvent = DismissModuleEvent;

declare global {
  interface HTMLElementEventMap {
    'dismiss-module': DismissModuleEvent;
    'disable-module': DisableModuleEvent;
  }
}

export interface ModulesElement {
  $: {
    modules: HTMLElement,
    removeModuleToast: CrToastElement,
    removeModuleToastMessage: HTMLElement,
    undoRemoveModuleButton: HTMLElement,
  };
}

/** Container for the NTP modules. */
export class ModulesElement extends PolymerElement {
  static get is() {
    return 'ntp-modules';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      disabledModules_: {
        type: Object,
        value: () => ({all: true, ids: []}),
      },

      dismissedModules_: {
        type: Array,
        value: () => [],
      },

      dragEnabled_: {
        type: Boolean,
        value: () => loadTimeData.getBoolean('modulesDragAndDropEnabled'),
        reflectToAttribute: true,
      },

      modulesLoaded_: Boolean,

      modulesLoadedAndVisibilityDetermined_: {
        type: Boolean,
        computed: `computeModulesLoadedAndVisibilityDetermined_(
          modulesLoaded_,
          modulesVisibilityDetermined_)`,
        observer: 'onModulesLoadedAndVisibilityDeterminedChange_',
      },

      modulesShownToUser: {
        type: Boolean,
        notify: true,
      },

      modulesVisibilityDetermined_: Boolean,

      /** Data about the most recently removed module. */
      removedModuleData_: {
        type: Object,
        value: null,
      },
    };
  }

  static get observers() {
    return ['onRemovedModulesChange_(dismissedModules_.*, disabledModules_)'];
  }

  private dismissedModules_: string[];
  private disabledModules_: {all: boolean, ids: string[]};
  private dragEnabled_: boolean;
  private modulesIdNames_: ModuleIdName[];
  private modulesLoaded_: boolean;
  private modulesLoadedAndVisibilityDetermined_: boolean;
  private modulesShownToUser: boolean;
  private modulesVisibilityDetermined_: boolean;
  private removedModuleData_: {message: string, undo?: () => void}|null;
  private setDisabledModulesListenerId_: number|null = null;
  private eventTracker_: EventTracker = new EventTracker();

  override connectedCallback() {
    super.connectedCallback();
    this.setDisabledModulesListenerId_ =
        NewTabPageProxy.getInstance()
            .callbackRouter.setDisabledModules.addListener(
                (all: boolean, ids: string[]) => {
                  this.disabledModules_ = {all, ids};
                  this.modulesVisibilityDetermined_ = true;
                });
    NewTabPageProxy.getInstance().handler.updateDisabledModules();
    this.eventTracker_.add(window, 'keydown', this.onWindowKeydown_.bind(this));
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    assert(this.setDisabledModulesListenerId_);
    NewTabPageProxy.getInstance().callbackRouter.removeListener(
        this.setDisabledModulesListenerId_);
    this.eventTracker_.removeAll();
  }

  override ready() {
    super.ready();
    this.renderModules_();
  }

  private appendModuleContainers_(moduleContainers: HTMLElement[]) {
    this.$.modules.innerHTML = window.trustedTypes!.emptyHTML;
    this.modulesShownToUser = false;
    moduleContainers.forEach((moduleContainer: HTMLElement) => {
      if (!moduleContainer.hidden) {
        this.modulesShownToUser = !moduleContainer.hidden;
      }
      this.$.modules.appendChild(moduleContainer);
    });
  }

  private async renderModules_(): Promise<void> {
    this.modulesIdNames_ =
        (await NewTabPageProxy.getInstance().handler.getModulesIdNames()).data;
    const modules =
        await ModuleRegistry.getInstance().initializeModulesHavingIds(
            this.modulesIdNames_.map(m => m.id),
            loadTimeData.getInteger('modulesLoadTimeout'));
    if (modules) {
      NewTabPageProxy.getInstance().handler.onModulesLoadedWithData(
          modules.map(module => module.descriptor.id));
      const moduleContainers =
          modules
              .map(module => {
                return module.elements.map(element => {
                  const moduleWrapper = new ModuleWrapperElement();
                  moduleWrapper.module = {
                    element,
                    descriptor: module.descriptor,
                  };
                  if (this.dragEnabled_) {
                    moduleWrapper.addEventListener(
                        'mousedown', e => this.onDragStart_(e));
                  }
                  moduleWrapper.addEventListener(
                      'dismiss-module', e => this.onDismissModule_(e));
                  moduleWrapper.addEventListener(
                      'disable-module', e => this.onDisableModule_(e));

                  const moduleContainer =
                      this.ownerDocument.createElement('div');
                  moduleContainer.classList.add('module-container');
                  moduleContainer.hidden =
                      this.moduleDisabled_(module.descriptor.id);
                  moduleContainer.appendChild(moduleWrapper);
                  return moduleContainer;
                });
              })
              .flat();

      chrome.metricsPrivate.recordSmallCount(
          'NewTabPage.Modules.LoadedModulesCount', modules.length);

      this.logModuleLoadedWithModules_(modules);
      this.appendModuleContainers_(moduleContainers);
      this.onModulesLoaded_();
    }
  }

  private logModuleLoadedWithModules_(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 onWindowKeydown_(e: KeyboardEvent) {
    let ctrlKeyPressed = e.ctrlKey;
    // <if expr="is_macosx">
    ctrlKeyPressed = ctrlKeyPressed || e.metaKey;
    // </if>
    if (ctrlKeyPressed && e.key === 'z') {
      this.onUndoRemoveModuleButtonClick_();
    }
  }

  private onModulesLoaded_() {
    this.modulesLoaded_ = true;
  }

  private computeModulesLoadedAndVisibilityDetermined_(): boolean {
    return this.modulesLoaded_ && this.modulesVisibilityDetermined_;
  }

  private onModulesLoadedAndVisibilityDeterminedChange_() {
    if (this.modulesLoadedAndVisibilityDetermined_) {
      this.modulesIdNames_.forEach(({id}) => {
        chrome.metricsPrivate.recordBoolean(
            `NewTabPage.Modules.EnabledOnNTPLoad.${id}`,
            !this.disabledModules_.all &&
                !this.disabledModules_.ids.includes(id));
      });
      chrome.metricsPrivate.recordBoolean(
          'NewTabPage.Modules.VisibleOnNTPLoad', !this.disabledModules_.all);
      this.dispatchEvent(new Event('modules-loaded'));
    }
  }

  /**
   * @param e Event notifying a module was dismissed. Contains the message to
   *     show in the toast.
   */
  private onDismissModule_(e: DismissModuleEvent) {
    const id = (e.target as ModuleWrapperElement).module.descriptor.id;
    const restoreCallback = e.detail.restoreCallback;
    this.removedModuleData_ = {
      message: e.detail.message,
      undo: restoreCallback ?
          () => {
            this.splice(
                'dismissedModules_', this.dismissedModules_.indexOf(id), 1);
            restoreCallback();
            NewTabPageProxy.getInstance().handler.onRestoreModule(id);
          } :
          undefined,
    };
    if (!this.dismissedModules_.includes(id)) {
      this.push('dismissedModules_', id);
    }

    // Notify the user.
    this.$.removeModuleToast.show();
    // Notify the backend.
    NewTabPageProxy.getInstance().handler.onDismissModule(id);
  }

  /**
   * @param e Event notifying a module was disabled. Contains the message to
   *     show in the toast.
   */
  private onDisableModule_(e: DisableModuleEvent) {
    const id = (e.target as ModuleWrapperElement).module.descriptor.id;
    const restoreCallback = e.detail.restoreCallback;
    this.removedModuleData_ = {
      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.$.removeModuleToast.show();
    chrome.metricsPrivate.recordSparseValueWithPersistentHash(
        'NewTabPage.Modules.Disabled', id);
    chrome.metricsPrivate.recordSparseValueWithPersistentHash(
        'NewTabPage.Modules.Disabled.ModuleRequest', id);
  }

  private moduleDisabled_(id: string): boolean {
    return this.disabledModules_.all || this.dismissedModules_.includes(id) ||
        this.disabledModules_.ids.includes(id);
  }

  private onUndoRemoveModuleButtonClick_() {
    if (!this.removedModuleData_) {
      return;
    }

    // Restore the module.
    this.removedModuleData_.undo!();

    // Notify the user.
    this.$.removeModuleToast.hide();

    this.removedModuleData_ = null;
  }

  /**
   * Hides and reveals modules depending on removed status.
   */
  private onRemovedModulesChange_() {
    this.shadowRoot!.querySelectorAll('ntp-module-wrapper')
        .forEach(moduleWrapper => {
          moduleWrapper.parentElement!.hidden =
              this.moduleDisabled_(moduleWrapper.module.descriptor.id);
        });
    // Append modules again to accommodate for empty space from removed module.
    const moduleContainers = [...this.shadowRoot!.querySelectorAll<HTMLElement>(
        '.module-container')];
    this.appendModuleContainers_(moduleContainers);
  }

  private onCustomizeModuleFre_() {
    this.dispatchEvent(
        new Event('customize-module', {bubbles: true, composed: true}));
  }

  /**
   * Module is dragged by updating the module position based on the
   * position of the pointer.
   */
  private onDragStart_(e: MouseEvent) {
    assert(loadTimeData.getBoolean('modulesDragAndDropEnabled'));

    const dragElement = e.target as HTMLElement;
    const dragElementRect = dragElement.getBoundingClientRect();
    // This is the offset between the pointer and module so that the
    // module isn't dragged by the top-left corner.
    const dragOffset = {
      x: e.x - dragElementRect.x,
      y: e.y - dragElementRect.y,
    };

    dragElement.parentElement!.style.width = `${dragElementRect.width}px`;
    dragElement.parentElement!.style.height = `${dragElementRect.height}px`;

    const undraggedModuleWrappers =
        [...this.shadowRoot!.querySelectorAll('ntp-module-wrapper')].filter(
            moduleWrapper => moduleWrapper !== dragElement);

    const dragOver = (e: MouseEvent) => {
      e.preventDefault();

      dragElement.setAttribute('dragging', '');
      dragElement.style.left = `${e.x - dragOffset.x}px`;
      dragElement.style.top = `${e.y - dragOffset.y}px`;
    };

    const dragEnter = (e: MouseEvent) => {
      // Move hidden module containers to end of list to ensure user's new
      // layout stays intact.
      const moduleContainers = [
        ...this.shadowRoot!.querySelectorAll<HTMLElement>(
            '.module-container:not([hidden])'),
        ...this.shadowRoot!.querySelectorAll<HTMLElement>(
            '.module-container[hidden]'),
      ];
      const dragIndex = moduleContainers.indexOf(dragElement.parentElement!);
      const dropIndex =
          moduleContainers.indexOf((e.target as HTMLElement).parentElement!);

      const dragContainer = moduleContainers[dragIndex];

      // To animate the modules as they are reordered we use the FLIP
      // (First, Last, Invert, Play) animation approach by @paullewis.
      // https://aerotwist.com/blog/flip-your-animations/

      // The first and last positions of the modules are used to
      // calculate how the modules have changed.
      const firstRects = undraggedModuleWrappers.map(moduleWrapper => {
        return moduleWrapper.getBoundingClientRect();
      });

      moduleContainers.splice(dragIndex, 1);
      moduleContainers.splice(dropIndex, 0, dragContainer);
      this.appendModuleContainers_(moduleContainers);

      undraggedModuleWrappers.forEach((moduleWrapper, i) => {
        const lastRect = moduleWrapper.getBoundingClientRect();
        const invertX = firstRects[i].left - lastRect.left;
        const invertY = firstRects[i].top - lastRect.top;
        moduleWrapper.animate(
            [
              // A translation is applied to invert the module and make it
              // appear to be in its first position when it actually is in its
              // final position already.
              {transform: `translate(${invertX}px, ${invertY}px)`, zIndex: 0},
              // Removing the inversion translation animates the module from
              // the fake first position to its current (final) position.
              {transform: 'translate(0)', zIndex: 0},
            ],
            {duration: 800, easing: 'ease'});
      });
    };

    undraggedModuleWrappers.forEach(moduleWrapper => {
      moduleWrapper.addEventListener('mouseover', dragEnter);
    });

    this.ownerDocument.addEventListener('mousemove', dragOver);
    this.ownerDocument.addEventListener('mouseup', () => {
      this.ownerDocument.removeEventListener('mousemove', dragOver);

      undraggedModuleWrappers.forEach(moduleWrapper => {
        moduleWrapper.removeEventListener('mouseover', dragEnter);
      });

      // The FLIP approach is also used for the dropping animation
      // of the dragged module because of the position changes caused
      // by removing the dragging styles. (Removing the styles after
      // the animation causes the animation to be fixed.)
      const firstRect = dragElement.getBoundingClientRect();

      dragElement.removeAttribute('dragging');
      dragElement.style.removeProperty('left');
      dragElement.style.removeProperty('top');

      const lastRect = dragElement.getBoundingClientRect();
      const invertX = firstRect.left - lastRect.left;
      const invertY = firstRect.top - lastRect.top;

      dragElement.animate(
          [
            {transform: `translate(${invertX}px, ${invertY}px)`, zIndex: 2},
            {transform: 'translate(0)', zIndex: 2},
          ],
          {duration: 800, easing: 'ease'});

      const moduleIds =
          [...this.shadowRoot!.querySelectorAll('ntp-module-wrapper')].map(
              moduleWrapper => moduleWrapper.module.descriptor.id);
      NewTabPageProxy.getInstance().handler.setModulesOrder(moduleIds);
    }, {once: true});
  }
}

customElements.define(ModulesElement.is, ModulesElement);