chromium/chrome/browser/resources/history/synced_device_manager.ts

// Copyright 2016 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/polymer/v3_0/iron-list/iron-list.js';
import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import './shared_style.css.js';
import './synced_device_card.js';
import './strings.m.js';

import type {CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrLazyRenderElement} from 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import {assert} from 'chrome://resources/js/assert.js';
import {FocusGrid} from 'chrome://resources/js/focus_grid.js';
import type {FocusRow} from 'chrome://resources/js/focus_row.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {Debouncer, microTask, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserServiceImpl} from './browser_service.js';
import {SYNCED_TABS_HISTOGRAM_NAME, SyncedTabsHistogram} from './constants.js';
import type {ForeignSession, ForeignSessionTab} from './externs.js';
import type {HistorySyncedDeviceCardElement} from './synced_device_card.js';
import {getTemplate} from './synced_device_manager.html.js';

interface ForeignDeviceInternal {
  device: string;
  lastUpdateTime: string;
  opened: boolean;
  separatorIndexes: number[];
  timestamp: number;
  tabs: ForeignSessionTab[];
  tag: string;
}

declare global {
  interface HTMLElementEventMap {
    'synced-device-card-open-menu':
        CustomEvent<{tag: string, target: HTMLElement}>;
  }
}

export interface HistorySyncedDeviceManagerElement {
  $: {
    'menu': CrLazyRenderElement<CrActionMenuElement>,
    'no-synced-tabs': HTMLElement,
    'sign-in-guide': HTMLElement,
  };
}

export class HistorySyncedDeviceManagerElement extends PolymerElement {
  static get is() {
    return 'history-synced-device-manager';
  }

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

  static get properties() {
    return {
      sessionList: {
        type: Array,
        observer: 'updateSyncedDevices',
      },

      searchTerm: {
        type: String,
        observer: 'searchTermChanged',
      },

      /**
       * An array of synced devices with synced tab data.
       */
      syncedDevices_: Array,

      signInState: {
        type: Boolean,
        observer: 'signInStateChanged_',
      },

      guestSession_: Boolean,
      signInAllowed_: Boolean,
      fetchingSyncedTabs_: Boolean,
      hasSeenForeignData_: Boolean,

      /**
       * The session ID referring to the currently active action menu.
       */
      actionMenuModel_: String,
    };
  }

  private focusGrid_: FocusGrid|null = null;
  private syncedDevices_: ForeignDeviceInternal[] = [];
  private hasSeenForeignData_: boolean;
  private fetchingSyncedTabs_: boolean = false;
  private actionMenuModel_: string|null = null;
  private guestSession_: boolean = loadTimeData.getBoolean('isGuestSession');
  private signInAllowed_: boolean = loadTimeData.getBoolean('isSignInAllowed');
  private debouncer_: Debouncer|null = null;

  signInState: boolean;
  searchTerm: string;
  sessionList: ForeignSession[];

  override ready() {
    super.ready();
    this.addEventListener('synced-device-card-open-menu', this.onOpenMenu_);
    this.addEventListener('update-focus-grid', this.updateFocusGrid_);
  }

  override connectedCallback() {
    super.connectedCallback();
    this.focusGrid_ = new FocusGrid();

    // Update the sign in state.
    BrowserServiceImpl.getInstance().otherDevicesInitialized();
    BrowserServiceImpl.getInstance().recordHistogram(
        SYNCED_TABS_HISTOGRAM_NAME, SyncedTabsHistogram.INITIALIZED,
        SyncedTabsHistogram.LIMIT);
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.focusGrid_!.destroy();
  }

  configureSignInForTest(data: {
    signInState: boolean,
    signInAllowed: boolean,
    guestSession: boolean,
  }) {
    this.signInState = data.signInState;
    this.signInAllowed_ = data.signInAllowed;
    this.guestSession_ = data.guestSession;
  }

  getContentScrollTarget(): HTMLElement {
    return this;
  }

  private createInternalDevice_(session: ForeignSession):
      ForeignDeviceInternal {
    let tabs: ForeignSessionTab[] = [];
    const separatorIndexes = [];
    for (let i = 0; i < session.windows.length; i++) {
      const windowId = session.windows[i].sessionId;
      const newTabs = session.windows[i].tabs;
      if (newTabs.length === 0) {
        continue;
      }

      newTabs.forEach(function(tab) {
        tab.windowId = windowId;
      });

      let windowAdded = false;
      if (!this.searchTerm) {
        // Add all the tabs if there is no search term.
        tabs = tabs.concat(newTabs);
        windowAdded = true;
      } else {
        const searchText = this.searchTerm.toLowerCase();
        for (let j = 0; j < newTabs.length; j++) {
          const tab = newTabs[j];
          if (tab.title.toLowerCase().indexOf(searchText) !== -1) {
            tabs.push(tab);
            windowAdded = true;
          }
        }
      }
      if (windowAdded && i !== session.windows.length - 1) {
        separatorIndexes.push(tabs.length - 1);
      }
    }
    return {
      device: session.name,
      lastUpdateTime: '– ' + session.modifiedTime,
      opened: true,
      separatorIndexes: separatorIndexes,
      timestamp: session.timestamp,
      tabs: tabs,
      tag: session.tag,
    };
  }

  private onTurnOnSyncClick_() {
    BrowserServiceImpl.getInstance().startTurnOnSyncFlow();
  }

  private onOpenMenu_(e: CustomEvent<{tag: string, target: HTMLElement}>) {
    this.actionMenuModel_ = e.detail.tag;
    this.$.menu.get().showAt(e.detail.target);
    BrowserServiceImpl.getInstance().recordHistogram(
        SYNCED_TABS_HISTOGRAM_NAME, SyncedTabsHistogram.SHOW_SESSION_MENU,
        SyncedTabsHistogram.LIMIT);
  }

  private onOpenAllClick_() {
    const menu = this.$.menu.getIfExists();
    assert(menu);
    const browserService = BrowserServiceImpl.getInstance();
    browserService.recordHistogram(
        SYNCED_TABS_HISTOGRAM_NAME, SyncedTabsHistogram.OPEN_ALL,
        SyncedTabsHistogram.LIMIT);
    assert(this.actionMenuModel_);
    browserService.openForeignSessionAllTabs(this.actionMenuModel_);
    this.actionMenuModel_ = null;
    menu.close();
  }

  private updateFocusGrid_() {
    if (!this.focusGrid_) {
      return;
    }

    this.focusGrid_.destroy();

    this.debouncer_ = Debouncer.debounce(this.debouncer_, microTask, () => {
      const cards =
          this.shadowRoot!.querySelectorAll('history-synced-device-card');
      Array.from(cards)
          .reduce(
              (prev: FocusRow[], cur: HistorySyncedDeviceCardElement) =>
                  prev.concat(cur.createFocusRows()),
              [])
          .forEach((row) => {
            this.focusGrid_!.addRow(row);
          });
      this.focusGrid_!.ensureRowActive(1);
    });
  }

  private onDeleteSessionClick_() {
    const menu = this.$.menu.getIfExists();
    assert(menu);
    const browserService = BrowserServiceImpl.getInstance();
    browserService.recordHistogram(
        SYNCED_TABS_HISTOGRAM_NAME, SyncedTabsHistogram.HIDE_FOR_NOW,
        SyncedTabsHistogram.LIMIT);
    assert(this.actionMenuModel_);
    browserService.deleteForeignSession(this.actionMenuModel_);
    this.actionMenuModel_ = null;
    menu.close();
  }

  clearSyncedDevicesForTest() {
    this.clearDisplayedSyncedDevices_();
  }

  private clearDisplayedSyncedDevices_() {
    this.syncedDevices_ = [];
  }

  /**
   * Decide whether or not should display no synced tabs message.
   */
  showNoSyncedMessage(
      signInState: boolean, syncedDevicesLength: number,
      guestSession: boolean): boolean {
    if (guestSession) {
      return true;
    }

    return signInState && syncedDevicesLength === 0;
  }

  /**
   * Shows the signin guide when the user is not signed in, signin is allowed
   * and not in a guest session.
   */
  showSignInGuide(
      signInState: boolean, guestSession: boolean,
      signInAllowed: boolean): boolean {
    const show = !signInState && !guestSession && signInAllowed;
    if (show) {
      BrowserServiceImpl.getInstance().recordAction(
          'Signin_Impression_FromRecentTabs');
    }

    return show;
  }

  /**
   * Decide what message should be displayed when user is logged in and there
   * are no synced tabs.
   */
  noSyncedTabsMessage(): string {
    let stringName = this.fetchingSyncedTabs_ ? 'loading' : 'noSyncedResults';
    if (this.searchTerm !== '') {
      stringName = 'noSearchResults';
    }
    return loadTimeData.getString(stringName);
  }

  /**
   * Replaces the currently displayed synced tabs with |sessionList|. It is
   * common for only a single session within the list to have changed, We try to
   * avoid doing extra work in this case. The logic could be more intelligent
   * about updating individual tabs rather than replacing whole sessions, but
   * this approach seems to have acceptable performance.
   */
  updateSyncedDevices(sessionList: ForeignSession[]) {
    this.fetchingSyncedTabs_ = false;

    if (!sessionList) {
      return;
    }

    if (sessionList.length > 0 && !this.hasSeenForeignData_) {
      this.hasSeenForeignData_ = true;
      BrowserServiceImpl.getInstance().recordHistogram(
          SYNCED_TABS_HISTOGRAM_NAME, SyncedTabsHistogram.HAS_FOREIGN_DATA,
          SyncedTabsHistogram.LIMIT);
    }

    const devices: ForeignDeviceInternal[] = [];
    sessionList.forEach((session) => {
      const device = this.createInternalDevice_(session);
      if (device.tabs.length !== 0) {
        devices.push(device);
      }
    });

    this.syncedDevices_ = devices;
  }

  /**
   * Get called when user's sign in state changes, this will affect UI of synced
   * tabs page. Sign in promo gets displayed when user is signed out, and
   * different messages are shown when there are no synced tabs.
   */
  private signInStateChanged_(_current: boolean, previous?: boolean) {
    if (previous === undefined) {
      return;
    }

    this.dispatchEvent(new CustomEvent(
        'history-view-changed', {bubbles: true, composed: true}));

    // User signed out, clear synced device list and show the sign in promo.
    if (!this.signInState) {
      this.clearDisplayedSyncedDevices_();
      return;
    }
    // User signed in, show the loading message when querying for synced
    // devices.
    this.fetchingSyncedTabs_ = true;
  }

  searchTermChanged() {
    this.clearDisplayedSyncedDevices_();
    this.updateSyncedDevices(this.sessionList);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'history-synced-device-manager': HistorySyncedDeviceManagerElement;
  }
}

customElements.define(
    HistorySyncedDeviceManagerElement.is, HistorySyncedDeviceManagerElement);