Source: js/rocketbar.js

'use strict';
/* global eventSafety */
/* global Service, SearchWindow, places, Promise, UtilityTray */

(function(exports) {

  /**
   * The Rocketbar is a system-wide URL/search/title bar.
   * @requires SearchWindow
   * @requires SettingsListener
   * @class Rocketbar
   */
  function Rocketbar() {

    // States
    this.enabled = false;
    this.focused = false;
    this.active = false;

    // Properties
    this._port = null; // Inter-app communications port
    this._wasClicked = false; // Remember when transition triggered by a click
    this._pendingMessage = null;

    // Get DOM elements
    this.body = document.body;
    this.screen = document.getElementById('screen');
    this.rocketbar = document.getElementById('rocketbar');
    this.form = document.getElementById('rocketbar-form');
    this.input = document.getElementById('rocketbar-input');
    this.cancel = document.getElementById('rocketbar-cancel');
    this.clearBtn = document.getElementById('rocketbar-clear');
    this.results = document.getElementById('rocketbar-results');
    this.backdrop = document.getElementById('rocketbar-backdrop');
  }

  Rocketbar.prototype = {
    EVENT_PREFIX: 'rocketbar',
    name: 'Rocketbar',

    /**
     * True during the rocketbar closing animation.
     */
    isClosing: false,

    publish: function(name) {
      window.dispatchEvent(new CustomEvent(this.EVENT_PREFIX + name, {
        detail: this
      }));
    },

    isActive: function() {
      return this.active;
    },

    getActiveWindow: function() {
      return this.isActive() ? this.searchWindow : null;
    },

    setHierarchy: function(active) {
      if (active) {
        this.focus();
      }
      this.searchWindow &&
      this.searchWindow.setVisibleForScreenReader(active);
      return true;
    },

    /**
     * Starts Rocketbar.
     * @memberof Rocketbar.prototype
     */
    start: function() {
      this.addEventListeners();
      this.enabled = true;
      Service.request('registerHierarchy', this);
      Service.registerState('enabled', this);
      Service.register('handleInput', this);
    },

    /**
     * Put Rocketbar in the active state.
     *
     * Input is displayed, title is hidden and search app is loaded, input
     * not always focused.
     * @return {Promise}
     * @memberof Rocketbar.prototype
     */
    activate: function() {
      if (this.isClosing) {
        return Promise.reject();
      }

      this._activateCall = new Promise(resolve => {
        if (this.active) {
          resolve();
          return;
        }

        this.active = true;
        this.rocketbar.classList.add('active');
        this.form.classList.remove('hidden');
        this.screen.classList.add('rocketbar-focused');

        // We wait for the transition do be over and the search app to be loaded
        // before moving on (and resolving the promise).
        var searchLoaded = false;
        var transitionEnded = false;
        var waitOver = () => {
          if (searchLoaded && transitionEnded) {
            resolve();
            this._activateCall = null;
            this.publish('-activated');
          }
        };

        var backdrop = this.backdrop;
        var finishTransition = () => {
          window.dispatchEvent(new CustomEvent('rocketbar-overlayopened'));
          transitionEnded = true;
          waitOver();
        };
        backdrop.classList.remove('hidden');
        eventSafety(backdrop, 'transitionend', finishTransition, 200);

        this.loadSearchApp().then(() => {
          if (this.input.value.length) {
            this.handleInput();
          }
          searchLoaded = true;
          waitOver();
        });
        this.publish('-activating');
      });

      // Immediately hide if the utility tray is active.
      // In the future we might be able to streamline this flow, but for now we
      // need to ensure that all events are properly fired so that the chrome
      // collapses. If for example we early exit, currently the chrome will
      // not collapse.
      if (UtilityTray.active || UtilityTray.shown) {
        this._activateCall
          .then(this._closeSearch.bind(this));
      }

      return this._activateCall;
    },

    /**
     * Take Rocketbar out of active state.
     *
     * Title is displayed, input is hidden, input is always blurred.
     * @memberof Rocketbar.prototype
     */
    deactivate: function() {
      if (!this.active) {
        return;
      }
      this.active = false;
      this.isClosing = true;

      var backdrop = this.backdrop;
      var self = this;
      var finish = () => {
        this.form.classList.add('hidden');
        this.rocketbar.classList.remove('active');
        this.screen.classList.remove('rocketbar-focused');

        backdrop.classList.add('hidden');

        eventSafety(backdrop, 'transitionend', () => {
          window.dispatchEvent(new CustomEvent('rocketbar-overlayclosed'));
          self.publish('-deactivated');
          self.isClosing = false;
        }, 200);
      };

      if (this.focused) {
        eventSafety(window, 'keyboardhidden', finish, 1000);
        this.blur();
      } else {
        finish();
      }
      this.publish('-deactivating');
    },

    /**
     * Add event listeners. Only called when Rocketbar is turned on.
     * @memberof Rocketbar.prototype
     */
    addEventListeners: function() {
      // Listen for events from window manager
      window.addEventListener('apploading', this);
      window.addEventListener('appforeground', this);
      window.addEventListener('apptitlechange', this);
      window.addEventListener('lockscreen-appopened', this);
      window.addEventListener('appopened', this);
      window.addEventListener('launchapp', this);
      window.addEventListener('searchterminated', this);
      window.addEventListener('permissiondialoghide', this);
      window.addEventListener('global-search-request', this);
      window.addEventListener('attentionopening', this);
      window.addEventListener('attentionopened', this);
      window.addEventListener('searchopened', this);
      window.addEventListener('searchclosed', this);
      window.addEventListener('utilitytray-overlayopening', this);
      window.addEventListener('utility-tray-overlayopened', this);
      window.addEventListener('simlockrequestfocus', this);
      window.addEventListener('cardviewbeforeshow', this);

      // Listen for events from Rocketbar
      this.input.addEventListener('focus', this);
      this.input.addEventListener('blur', this);
      this.input.addEventListener('input', this);
      this.cancel.addEventListener('click', this);
      this.clearBtn.addEventListener('click', this);
      this.form.addEventListener('submit', this);
      this.backdrop.addEventListener('click', this);

      // Listen for messages from search app
      window.addEventListener('iac-search-results', this);
    },

    '_handle_system-resize': function(evt) {
      if (this.isActive()) {
        var p = this.searchWindow &&
          this.searchWindow.frontWindow &&
          this.searchWindow.frontWindow.resize();

        if (evt.detail && typeof evt.detail.waitUntil === 'function') {
          evt.detail.waitUntil(p);
        }
        return false;
      }
      return true;
    },

    respondToHierarchyEvent: function(evt) {
      if (this['_handle_' + evt.type]) {
        return this['_handle_' + evt.type](evt);
      }
      return true;
    },

    /**
     * Dispatch events to correct event handlers.
     *
     * @param {Event} e Event.
     * @memberof Rocketbar.prototype
     */
    handleEvent: function(e) {
      switch(e.type) {
        case 'searchopened':
          window.addEventListener('open-app', this);
          break;
        case 'searchclosed':
          window.removeEventListener('open-app', this);
          break;
        case 'apploading':
        case 'launchapp':
          // Do not close the search app if something opened in the background.
          var detail = e.detail;
          if (detail && detail.stayBackground) {
            return;
          }
          this._closeSearch();
          break;
        case 'open-app':
          // Do not hide the searchWindow if we have a frontWindow.
          if (this.searchWindow && this.searchWindow.frontWindow) {
            return;
          }
          if (e.detail && !e.detail.showApp) {
            return;
          }
          /* falls through */
        case 'attentionopening':
        case 'attentionopened':
        case 'appforeground':
        case 'appopened':
        case 'simlockrequestfocus':
        case 'cardviewbeforeshow':

        // Hide rocketbar if the user opens the utility tray.
        // The utility tray and rocketbar share the same space in the mental
        // model - only one can be active at any given time. For consistency,
        // any activities are also closed along with rocketbar when the
        // utility tray is opened.
        case 'utilitytray-overlayopening':
        case 'utility-tray-overlayopened':
          this._closeSearch();
          break;
        case 'lockscreen-appopened':
          this.handleLock(e);
          break;
        case 'focus':
          this.handleFocus(e);
          break;
        case 'blur':
          this.handleBlur(e);
          break;
        case 'input':
          this.handleInput(e);
          break;
        case 'click':
          if (e.target == this.cancel) {
            this.handleCancel(e);
          } else if (e.target == this.clearBtn) {
            this.clear();
          } else if (e.target == this.backdrop) {
            this._closeSearch();
          }
          break;
        case 'searchterminated':
          this.handleSearchTerminated(e);
          break;
        case 'submit':
          this.handleSubmit(e);
          break;
        case 'iac-search-results':
          this.handleSearchMessage(e);
          break;
        case 'permissiondialoghide':
          if (this.active) {
            this.focus();
          }
          break;
        case 'global-search-request':
          var app = Service.query('AppWindowManager.getActiveWindow');
          var afterActivate;

          if (app && !app.isActive()) {
            return;
          }

          // If the app is not a browser, retain the search value and activate.
          if (app && !app.isBrowser()) {
            afterActivate = this.focus.bind(this);
          } else {
            // Clear the input if the URL starts with a system page.
            if (app.config.url.startsWith('app://system.gaiamobile.org')) {
              this.setInput('');
            } else {
              // Set the input to be the URL in the case of a normal browser.
              this.setInput(app.config.url);
            }

            afterActivate = () => {
              this.hideResults();
              setTimeout(() => {
                this.focus();
                this.selectAll();
              });
            };
          }

          if (app && app.appChrome && !app.appChrome.isMaximized()) {
            app.appChrome.maximize(() => {
              this.activate().then(afterActivate);
            });
          } else {
            this.activate().then(afterActivate);
          }
          break;
      }
    },

    /**
     * Set URL and generate manifest URL of search app.
     * @param {String} url The search app URL.
     * @memberof Rocketbar.prototype
     */
    setSearchAppURL: function(url) {
      this._searchAppURL = url;
      this._searchManifestURL = url ? url.match(/(^.*?:\/\/.*?\/)/)[1] +
        'manifest.webapp' : '';
    },

    setInput: function(input) {
      this.input.value = input;
      this.rocketbar.classList.toggle('has-text', input.length);
    },

    /**
     * Show the Rocketbar results pane.
     * @memberof Rocketbar.prototype
     */
    showResults: function() {
      if (this.searchWindow && !this.searchWindow.isDead()) {
        this.searchWindow.open();
      }
      this.results.classList.remove('hidden');
      this.backdrop.classList.add('results-shown');
    },

    /**
     * Hide the Rocketbar results pane.
     * @memberof Rocketbar.prototype
     */
    hideResults: function() {
      if (this.searchWindow) {
        this.searchWindow.close();
        this.searchWindow.hideContextMenu();
      }

      this.results.classList.add('hidden');
      this.backdrop.classList.remove('results-shown');

      // Send a message to the search app to clear results
      if (this._port) {
        this._port.postMessage({
          action: 'clear'
        });
      }
    },

    /**
     * Reset the Rocketbar to its initial empty state.
     */
    clear: function() {
      this.setInput('');
      this.hideResults();
    },

    /**
     * Enable back button.
     */
    enableNavigation: function() {
      this.rocketbar.classList.add('navigation');
    },

    /**
     * Disable back button.
     */
    disableNavigation: function() {
      this.rocketbar.classList.remove('navigation');
    },

    /**
     * Focus Rocketbar input.
     * @memberof Rocketbar.prototype
     */
    focus: function() {
      if (this.active) {
        this.input.focus();
      }
    },

    /**
     * SelectAll text content from Rocketbar input.
     * @memberof Rocketbar.prototype
     */
    selectAll: function() {
      this.input.setSelectionRange(0, this.input.value.length, 'forward');
    },

    /**
     * Handle a focus event.
     * @memberof Rocketbar.prototype
     */
    handleFocus: function() {
      this.focused = true;
    },

    /**
     * Handle press of hardware home button.
     * @memberof Rocketbar.prototype
     */
    _handle_home: function() {
      this._closeSearch();
      return true;
    },

    /**
     * Blur Rocketbar input.
     * @memberof Rocketbar.prototype
     */
    blur: function() {
      this.input.blur();
    },

    /**
     * Handle a blur event.
     * @memberof Rocketbar.prototype
     */
    handleBlur: function() {
      this.focused = false;
    },

    /**
     * Handle a lock event.
     * @memberof Rocketbar.prototype
     */
    handleLock: function() {
      this._closeSearch();
    },

    /**
     * This function is called in respondToHierarchyEvent()
     * when there is a value selector event and rocketbar
     * is the current top most UI by HierarchyManager.
     * @param  {Object} evt Event object
     */
    '_handle_mozChromeEvent': function(evt) {
      if (!evt.detail || evt.detail.type !== 'inputmethod-contextchange') {
        return true;
      }
      if (this.searchWindow) {
        this.searchWindow.getTopMostWindow()
            .broadcast('inputmethod-contextchange',
          evt.detail);
        return false;
      }
      return true;
    },

    /**
     * Handles activities for the search app.
    * @memberof Rocketbar.prototype
     */
    _handle_launchactivity: function(e) {
      if (e.detail.isActivity && e.detail.inline && this.searchWindow &&
          this.searchWindow.manifestURL === e.detail.parentApp) {
        this.searchWindow.broadcast('launchactivity', e.detail);
        return false;
      }
      return true;
    },

    _closeSearch: function() {
      var hideAndDeactivate = () => {
        this.hideResults();
        this.deactivate();
      };

      if (this._activateCall) {
        this._activateCall
          .then(hideAndDeactivate);
      } else {
        hideAndDeactivate();
      }
    },

    /**
     * Handle text input in Rocketbar.
     * @memberof Rocketbar.prototype
     */
    handleInput: function() {
      var input = this.input.value;

      this.rocketbar.classList.toggle('has-text', input.length);

      if (UtilityTray.active || UtilityTray.shown) {
        this._closeSearch();
        return;
      }

      if (!input && !this.results.classList.contains('hidden')) {
        this.hideResults();
        return;
      }

      if (this.results.classList.contains('hidden')) {
        this.showResults();
      }

      if (this._port) {
        this._port.postMessage({
          action: 'change',
          input: input,
          isPrivateBrowser:
            Service.query('AppWindowManager.getActiveWindow').isPrivateBrowser()
        });
      }
    },

    /**
     * Handle click of cancel button.
     * @memberof Rocketbar.prototype
     */
    handleCancel: function(e) {
      this.setInput('');
      this._closeSearch();
    },

    /**
     * Handle submission of the Rocketbar.
     *
     * @param {Event} e Submit event.
     * @memberof Rocketbar.prototype
     */
    handleSubmit: function(e) {
      e.preventDefault();

      this.input.blur();

      if (this.results.classList.contains('hidden')) {
        this.showResults();
      }

      this._port.postMessage({
        action: 'submit',
        input: this.input.value
      });

      // Do not persist search submissions from private windows.
      if (Service.query('AppWindowManager.getActiveWindow')
                 .isPrivateBrowser()) {
        this.setInput('');
      }
    },

    /**
     * Instantiates a new SearchWindow.
     * @return {Promise}
     * @memberof Rocketbar.prototype
     */
    loadSearchApp: function() {
      if (!this.searchWindow) {
        this.searchWindow = new SearchWindow();
      }

      return this.initSearchConnection();
    },

    /**
     * Handles when the search app terminates.
     * @memberof Rocketbar.prototype
     */
    handleSearchTerminated: function(e) {
      if (!this.searchWindow) {
        return;
      }

      this._closeSearch();

      this.searchWindow = null;
      this._port = null;
    },

    /**
     * Initialise inter-app connection with search app.
     * @return {Promise}
     * @memberof Rocketbar.prototype
     */
    initSearchConnection: function() {
      if (this.pendingInitConnection) {
        return this.pendingInitConnection;
      }

      this.pendingInitConnection = new Promise((resolve, reject) => {
        navigator.mozApps.getSelf().onsuccess = (event) => {
          var app = event.target.result;
          if (!app) {
            reject();
            return;
          }

          app.connect('search').then(ports => {
              ports.forEach(port => {
                this._port = port;
              });
              if (this._pendingMessage) {
                this.handleSearchMessage(this._pendingMessage);
                delete this._pendingMessage;
              }
              delete this.pendingInitConnection;
              resolve();
            }, reject);
        };
      });
      return this.pendingInitConnection;
    },

    /**
     * Handle messages from the search app.
     *
     * @param {Event} e Message event.
     * @memberof Rocketbar.prototype
     */
    handleSearchMessage: function(e) {
      // Open the search connection if we receive a message before it's open
      if (!this._port) {
        this._pendingMessage = e;
        this.initSearchConnection();
        return;
      }

      switch (e.detail.action) {
        case 'private-window':
          window.dispatchEvent(new CustomEvent('new-private-window'));
          break;
        case 'render':
          this.activate().then(this.focus.bind(this));
          break;
        case 'focus':
          this.focus();
          break;
        case 'input':
          this.setInput(e.detail.input);
          this.focus();
          this.handleInput();
          break;
        case 'request-screenshot':
          places.screenshotRequested(e.detail.url);
          break;
        case 'hide':
          this._closeSearch();
          break;
      }
    },

    /**
     * Tell the search app to update its index.
     * @memberof Rocketbar.prototype
     */
    updateSearchIndex: function() {
      if (this._port) {
        this._port.postMessage({
          action: 'syncPlaces'
        });
      }
    }
  };

  exports.Rocketbar = Rocketbar;

}(window));