Reference Source

coral-spectrum/coral-messenger/src/scripts/Messenger.js

/**
 * Copyright 2021 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {commons} from '../../../coral-utils';

const SCOPE_SELECTOR = ':scope > ';

/**
 * Messenger will used to pass the messages from child component to its parent. Currently we are relying on
 * events to do the job. When a large DOM is connected, these events as a bulk leads to delays.
 * With the use of messenger we will directly call the parent method provided in the observed messages list.
 * The current implmentation only supports one to many mapping i.e. one parent and many children and any
 * in child property will result in execution of only one parent method. This should be used purely for
 * coral internal events and not any DOM based or public events.
 *
 * Limitations :
 * - This doesnot support the case where any change in child property, needs to be notified
 *   to two or more parents. This is achievable, but not currently supported.
 * - Use this to post message only coral internal events.
 * - Do not use for DOM events or public events.
 * @private
 */

class Messenger {
  /** @ignore */
  constructor(element) {
    this._element = element;
    this._connected = false;
    this._clearQueue();
    this._clearListeners();
  }

  /**
   * checks whether Messenger is connected or not.
   * @returns {Boolean} true if connected
   * @private
   */
  get isConnected() {
    return this._connected === true;
  }

  /**
   * checks whether the event is silenced or not
   * @returns {Boolean} true if silenced
   * @private
   */
  get isSilenced() {
    return this._element._silenced === true;
  }

  /**
   * specifies the list of listener attached to messenger.
   * @returns {Array} array of listeners
   * @private
   */
  get listeners() {
    return this._listeners;
  }

  /**
   * add a message to the queue only if messenger is not connected
   * message will be added only if element is not connected.
   * @private
   */
  _addMessageToQueue(message, detail) {
    if(!this.isConnected) {
      this._queue.push({
        message: message,
        detail: detail
      });
    }
  }

  /**
   * executes the stored queue messages.
   * It will be executed when element is connected.
   * @private
   */
  _executeQueue() {
    this._queue.forEach((options) => {
      this._postMessage(options.message, options.detail);
    });
    this._clearQueue();
  }

  /**
   * empty the stored queue message
   * @private
   */
  _clearQueue() {
    this._queue = [];
  }

  /**
   * clears the listeners
   * @private
   */
  _clearListeners() {
    this._listeners = [];
  }

  /**
   * element should call this method when they are connected in DOM.
   * its the responsibility of the element to call this hook
   * @triggers `${element.tagName.toLowerCase()}:_messengerconnected`
   * @private
   */
  connect() {
    if(!this.isConnected) {
      let element = this._element;

      this._connected = true;

      element.trigger(`${element.tagName.toLowerCase()}:_messengerconnected`, {
        handler : this.registerListener.bind(this)
      });
      // post all stored messages
      this._executeQueue();
    }
  }

  /**
   * add the listener to messenger
   * this handler will be passed when messengerconnect event is trigger
   * the handler needs to be executed by listeners.
   * @private
   */
  registerListener(listener) {
    if(listener) {
      this._listeners.push(listener);
    }
  }

  /**
   * post the provided message to all listener.
   * @param {String} message which should be posted
   * @param {Object} additional detail which needs to be posted.
   * @private
   */
  _postMessage(message, detail) {
    let element = this._element;
    this.listeners.forEach((listener) => {
      let observedMessages = listener.observedMessages;
      let messageInfo = observedMessages[message];

      if(messageInfo) {
        let selector;
        let handler;
        if(typeof messageInfo === 'string') {
          selector = "*";
          handler = messageInfo;
        } else if(typeof messageInfo === 'object') {
          selector = messageInfo.selector || "*";
          handler = messageInfo.handler;
        }

        if(selector.indexOf(SCOPE_SELECTOR) === 0 ) {
          if(!listener.id) {
            listener.id = commons.getUID();
          }
          selector = selector.replace(SCOPE_SELECTOR, `#${listener.id} > `);
        }

        if(element.matches(selector)) {
          listener[handler].call(listener, new Event({
            target: element,
            detail: detail,
            type: message,
            currentTarget: listener
          }));
        }
      }
    });
  }

  /**
    * post the provided message to all listener,
    * along with validating silencing and storing in queue
    * @param {String} message which should be posted
    * @param {Object} additional detail which needs to be posted.
    * @private
   */
  postMessage(message, detail) {
    if(this.isSilenced) {
      return;
    }

    if(!this.isConnected) {
      this._addMessageToQueue(message, detail);
      return;
    }

    // element got disconnect and messenger not notified.
    if(!this._element.isConnected) {
      // disconnect messenger and again post the same message,
      // message will get store in queue.
      this.disconnect();
      this.postMessage(message, detail);
      return;
    }

    this._postMessage(message, detail);
  }

  /**
    * element should call this method when they are disconnected from DOM.
    * Its the responsibility of the element to call this hook
    * @private
   */
  disconnect() {
    if(this.isConnected) {
      this._connected = false;
      this._clearListeners();
      this._clearQueue();
    }
  }
}

/**
 * This Event class is just a bogus class, current message callback aspects
 * actual event as a parameter, since we are directly calling the method instead
 * of triggering event, will pass an instance of this disguised object,
 * to avoid breaks.
 * This just disguise the  most used functionality of event object
 * @private
 */
class Event {
  constructor(options) {
    this._detail = options.detail;
    this._target = options.target;
    this._type = options.type;
    this._currentTarget = options.currentTarget;
    this._defaultPrevented = false;
    this._propagationStopped = false;
    this._immediatePropagationStopped = false;
  }

  get detail() {
    return this._detail;
  }

  get type() {
    return this._type;
  }

  get target() {
    return this._target;
  }

  get currentTarget() {
    return this._currentTarget;
  }

  preventDefault() {
    this._defaultPrevented = true;
  }

  stopPropagation() {
    this._propagationStopped = true;
  }

  stopImmediatePropagation() {
    this._immediatePropagationStopped = true;
  }
}

export default Messenger;