Source: channel.js

'use strict';

const EventEmitter = require('events').EventEmitter;
const inherits = require('util').inherits;

const MembersEntity = require('./data/members');
const Member = require('./member');
const MessagesEntity = require('./data/messages');
const JsonDiff = require('../../util/jsondiff');
const log = require('loglevel');

const fieldMappings = {
  attributes: 'attributes',
  createdBy: 'createdBy',
  dateCreated: 'dateCreated',
  dateUpdated: 'dateUpdated',
  friendlyName: 'friendlyName',
  lastConsumedMessageIndex: 'lastConsumedMessageIndex',
  name: 'friendlyName',
  sid: 'sid',
  status: 'status',
  type: 'type',
  uniqueName: 'uniqueName'
};

/**
 * @class
 * @classdesc A Channel represents a remote channel of communication between
 * multiple IP Messaging Clients.
 * @property {Object} attributes - The Channel's custom attributes.
 * @property {String} createdBy - The identity of the User that created this Channel.
 * @property {Date} dateCreated - The Date this Channel was created.
 * @property {Date} dateUpdated - The Date this Channel was last updated.
 * @property {String} friendlyName - The Channel's name.
 * @property {Boolean} isPrivate - Whether the channel is private (as opposed to public).
 * @property {Number} lastConsumedMessageIndex - Index of the last Message the User has consumed in this Channel.
 * @property {Map<Sid, Member>} members - A map of Members in the Channel.
 * @property {Array<Message>} messages - An sorted array of Messages in the Channel.
 * @property {String} sid - The Channel's unique system identifier.
 * @property {Enumeration} status - Whether the Channel is 'known' to local Client, Client is 'invited' to or
 *   is 'joined' to this Channel.
 * @property {Enumeration} type - The Channel's type as a String: ['private', 'public']
 * @property {String} uniqueName - The Channel's unique name (tag).
 * @fires Channel#memberJoined
 * @fires Channel#memberLeft
 * @fires Channel#memberUpdated
 * @fires Channel#messageAdded
 * @fires Channel#messageRemoved
 * @fires Channel#messageUpdated
 * @fires Channel#typingEnded
 * @fires Channel#typingStarted
 * @fires Channel#updated
 */

function Channel(session, data, sid) {
  if (!(this instanceof Channel)) {
    return new Channel(session, data, sid);
  }

  let attributes = data.attributes || { };
  let createdBy = data.createdBy;
  let dateCreated = data.dateCreated;
  let dateUpdated = data.dateUpdated;
  let friendlyName = data.name || data.friendlyName;
  let lastConsumedMessageIndex = data.lastConsumedMessageIndex || null;
  let status = 'known';
  let type = data.type || Channel.type.PUBLIC;
  let uniqueName = data.uniqueName || null;
  let uri = data.channelUrl;

  if (data.isPrivate) {
    type = Channel.type.PRIVATE;
  }

  try {
    JSON.stringify(attributes);
  } catch (e) {
    throw new Error('Attributes must be a valid JSON object.');
  }

  let members = new Map();
  let membersEntity = new MembersEntity(this, session, members);
  membersEntity.on('memberJoined', this.emit.bind(this, 'memberJoined'));
  membersEntity.on('memberLeft', this.emit.bind(this, 'memberLeft'));
  membersEntity.on('memberUpdated', this.emit.bind(this, 'memberUpdated'));

  let messages = [];
  let messagesEntity = new MessagesEntity(this, session, messages);
  messagesEntity.on('messageAdded', this.emit.bind(this, 'messageAdded'));
  messagesEntity.on('messageUpdated', this.emit.bind(this, 'messageUpdated'));
  messagesEntity.on('messageRemoved', this.emit.bind(this, 'messageRemoved'));

  Object.defineProperties(this, {
    _attributes: {
      get: () => attributes,
      set: (_attributes) => attributes = _attributes
    },
    _createdBy: {
      get: () => createdBy,
      set: (_createdBy) => createdBy = _createdBy
    },
    _dateCreated: {
      get: () => dateCreated,
      set: (_dateCreated) => dateCreated = _dateCreated
    },
    _dateUpdated: {
      get: () => dateUpdated,
      set: (_dateUpdated) => dateUpdated = _dateUpdated
    },
    _friendlyName: {
      get: () => friendlyName,
      set: (_friendlyName) => friendlyName = _friendlyName
    },
    _lastConsumedMessageIndex: {
      get: () => lastConsumedMessageIndex,
      set: (_lastConsumedMessageIndex) => lastConsumedMessageIndex = _lastConsumedMessageIndex
    },
    _type: {
      get: () => type,
      set: (_type) => type = _type
    },
    _sid: {
      get: () => sid,
      set: (_sid) => sid = _sid
    },
    _status: {
      get: () => status,
      set: (_status) => status = _status
    },
    _uniqueName: {
      get: () => uniqueName,
      set: (_uniqueName) => uniqueName = _uniqueName
    },
    _entityPromise: { value: null, writable: true },
    _subscribePromise: { value: null, writable: true },
    _lastTypingUpdate: { value: 0, writable: true },
    _membersEntity: { value: membersEntity },
    _messagesEntity: { value: messagesEntity },
    _session: { value: session },
    _uri: { value: uri, writable: true },
    attributes: {
      enumerable: true,
      get: () => attributes
    },
    createdBy: {
      enumerable: true,
      get: () => createdBy
    },
    dateCreated: {
      enumerable: true,
      get: () => dateCreated
    },
    dateUpdated: {
      enumerable: true,
      get: () => dateUpdated
    },
    friendlyName: {
      enumerable: true,
      get: () => friendlyName
    },
    isPrivate: {
      enumerable: true,
      get: () => this._type === Channel.type.PRIVATE
    },
    lastConsumedMessageIndex: {
      enumerable: true,
      get: () => lastConsumedMessageIndex
    },
    members: {
      enumerable: true,
      value: members
    },
    messages: {
      enumerable: true,
      value: messages
    },
    sid: {
      enumerable: true,
      get: () => sid
    },
    status: {
      enumerable: true,
      get: () => status
    },
    type: {
      enumerable: true,
      get: () => type
    },
    uniqueName: {
      enumerable: true,
      get: () => uniqueName
    }
  });

  EventEmitter.call(this);
}

inherits(Channel, EventEmitter);

/**
 * The type of Channel (Public or private).
 * @readonly
 * @enum {String}
 */
Channel.type = {
  /** 'public' | This channel is Public. */
  PUBLIC: 'public',
  /** 'private' | This channel is Private. */
  PRIVATE: 'private'
};

/**
 * The status of the Channel, relative to the Client.
 * @readonly
 * @enum {String}
 */
Channel.status = {
  /** 'known' | This Client knows about the Channel, but the User is neither joined nor invited to it. */
  KNOWN: 'known',
  /** 'invited' | This Client's User is invited to the Channel. */
  INVITED: 'invited',
  /** 'joined' | This Client's User is joined to the Channel. */
  JOINED: 'joined',
  /** 'failed' | This Channel is malformed, or has failed to load. */
  FAILED: 'failed'
};

Object.freeze(Channel.type);
Object.freeze(Channel.status);

/**
 * Load and Subscribe to this Channel and do not subscribe to its Members and Messages.
 * This or _subscribeStreams will need to be called before any events on Channel will fire.
 * @returns {Promise}
 * @private
 */
Channel.prototype._subscribe = function _subscribe() {
  if (this._entityPromise) { return this._entityPromise; }

  this._entityPromise = this._session.datasync.openEntity(this._uri).then((entity) => {
    this._entity = entity;
    entity.subscribe();
    entity.on('updated', (data) => this._update(data));
    this._update(entity.getData());
    return this._entity;
  });
  return this._entityPromise;
};


/**
 * Load the attributes of this Channel and instantiate its Members and Messages.
 * This or _subscribe will need to be called before any events on Channel will fire.
 * This will need to be called before any events on Members or Messages will fire
 * @returns {Promise}
 * @private
 */
Channel.prototype._subscribeStreams = function _subscribeStreams() {
  this._subscribePromise = this._subscribePromise || this._subscribe().then((entity) => {
    let messagesUri = entity.value('/messagesUrl');
    let rosterUri = entity.value('/rosterUrl');
    return Promise.all([
      this._messagesEntity.subscribe(messagesUri).then(() => this._messagesEntity.getMessages()),
      this._membersEntity.subscribe(rosterUri)
    ]);
  }).then(() => this._entity);
  return this._subscribePromise;
};

/**
 * Load the Channel state.
 * @returns {Promise}
 * @private
 */
Channel.prototype._fetch = function _fetch() {
  return this._session.datasync.openEntity(this._uri).then((entity) => entity.getData());
};

/**
 * Stop listening for and firing events on this Channel.
 * @returns {Promise}
 * @private
 */
Channel.prototype._unsubscribe = function() {
  let promises = [];
  if (this._entityPromise) {
    promises.push(this._entity.unsubscribe());
  }

  promises.push(this._membersEntity.unsubscribe());
  promises.push(this._messagesEntity.unsubscribe());
  this._entityPromise = null;
  this._subscribePromise = null;
  return Promise.all(promises);
};

/**
 * Set channel status
 * @private
 */
Channel.prototype._setStatus = function(status) {
  if (this._status === status) { return; }

  this._status = status;

  if (status === 'joined') {
    this._subscribeStreams();
  } else if (status === 'invited') {
    this._subscribe();
  } else if (this._entityPromise) {
    this._unsubscribe();
  }
};

/**
 * Updates local channel object with new values
 * @private
 */
Channel.prototype._update = function(update) {
  try {
    if (typeof update.attributes === 'string') {
      update.attributes = JSON.parse(update.attributes);
    } else if (update.attributes) {
      JSON.stringify(update.attributes);
    }
  } catch (e) {
    log.warn('Retrieved malformed attributes from the server for channel: ' + this._sid);
    update.attributes = {};
  }

  let updated = false;
  for (let key in update) {
    let localKey = fieldMappings[key];
    if (localKey && localKey === fieldMappings.attributes) {
      if (!JsonDiff.isDeepEqual(this._attributes, update.attributes)) {
        this._attributes = update.attributes;
        updated = true;
      }
    } else if (localKey && this[localKey] !== update[key]) {
      this['_' + localKey] = update[key];
      updated = true;
    }
  }
  // if uniqueName is not present in the update - then we should set it to undefined on the client object
  if (!update.status && !update.uniqueName) {
    if (this._uniqueName) {
      this._uniqueName = null;
      updated = true;
    }
  }

  if (this._dateCreated && !(this._dateCreated instanceof Date)) {
    this._dateCreated = new Date(this._dateCreated);
  }

  if (this._dateUpdated && !(this._dateUpdated instanceof Date)) {
    this._dateUpdated = new Date(this._dateUpdated);
  }

  if (updated) { this.emit('updated', this); }
};

/**
 * Add a Client to the Channel by its Identity.
 * @param {String} identity - Identity of the Client to add.
 * @returns {Promise}
 */
Channel.prototype.add = function addByIdentity(identity) {
  if (!identity || typeof identity !== 'string') {
    throw new Error('Channel.add requires an <String>identity parameter');
  }

  return this._membersEntity.add(identity);
};

/**
 * Decline an invitation to the Channel.
 * @returns {Promise<Channel>}
 */
Channel.prototype.decline = function declineChannel() {
  return this._session.addCommand('declineInvitation', {
    channelSid: this._sid
  }).then(()=>this);
};

/**
 * Delete the Channel.
 * @returns {Promise<Channel>}
 */
Channel.prototype.delete = function deleteChannel() {
  return this._session.addCommand('destroyChannel', {
    channelSid: this._sid
  }).then(()=>this);
};

/**
 * Invite a user to the Channel by their Identity.
 * @param {String} identity - Identity of the user to invite.
 * @returns {Promise}
 */
Channel.prototype.invite = function inviteByIdentity(identity) {
  if (typeof identity !== 'string' || !identity.length) {
    throw new Error('Channel.invite requires an <String>identity parameter');
  }

  return this._membersEntity.invite(identity);
};

/**
 * Set last consumed Channel's Message index to current consumption horizon.
 * @param {Number} index - Message index to set as last read.
 * @returns {Promise}
 */
Channel.prototype.updateLastConsumedMessageIndex = function updateLastConsumedMessageIndex(index) {
  if (parseInt(index) !== index) {
    let err = 'Channel.updateLastConsumedMessageIndex requires an integral <Number>index parameter';
    throw new Error(err);
  }

  return this._subscribeStreams().then(() => {
    return this._session.sendLastConsumedMessageIndexForChannel(this._sid, index);
  }).then(() => this);
};

/**
 * Get the custom attributes of this channel.
 * NOTE: Attributes will be empty in public channels until this is called.
 * However, private channels will already have this due to back-end limitation.
 * @returns {Promise<Object>}
 */
Channel.prototype.getAttributes = function getAttributes() {
  if (this._entityPromise) {
    return this._subscribe().then(() => this.attributes);
  }

  return this._fetch().then((data) => {
    this._update(data);
    return this.attributes;
  });
};

/**
 * Get a sorted list of Messages.
 * @param {Number} [count] - Amount of Messages to fetch
 * @param {String} [anchor='end'] - Newest Message to fetch
 * @returns {Promise<Array<Message>>}
 */
Channel.prototype.getMessages = function getMessages(count, anchor) {
  return this._subscribeStreams().then(() => this._messagesEntity.getMessages(count, anchor));
};

/**
 * Get a list of all Members joined to this Channel.
 * @returns {Promise<Array<Member>>}
 */
Channel.prototype.getMembers = function getMembers() {
  return this._subscribeStreams().then(() => this._membersEntity.getMembers());
};

/**
 * Join the Channel.
 * @returns {Promise<Channel>}
 */
Channel.prototype.join = function joinChannel() {
  return this._session.addCommand('joinChannel', {
    channelSid: this._sid
  }).then(() => this);
};

/**
 * Leave the Channel.
 * @returns {Promise<Channel>}
 */
Channel.prototype.leave = function leaveChannel() {
  if (this._status !== Channel.status.JOINED) { return Promise.resolve(this); }

  return this._session.addCommand('leaveChannel', {
    channelSid: this._sid
  }).then(() => this);

};

/**
 * Remove a Member from the Channel.
 * @param {Member|String} member - The Member (Or identity) to remove.
 * @returns {Promise<Member>}
 */
Channel.prototype.removeMember = function removeMember(member) {
  if (!member || (typeof member !== 'string' && !(member instanceof Member))) {
    throw new Error('Channel.removeMember requires a <String|Member>member parameter.');
  }

  return this._membersEntity.remove(typeof member === 'string' ? member : member.identity);
};

/**
 * Send a Message on the Channel.
 * @param {String} messageBody - The message body.
 * @returns {Promise<String>} A Promise for the message ID
 */
Channel.prototype.sendMessage = function sendMessage(messageBody) {
  return this._messagesEntity.send(messageBody);
};

/**
 * Send a notification to the server indicating that this Client is currently typing in this Channel.
 * @returns {Promise}
 */
Channel.prototype.typing = function typing() {
  if (this._lastTypingUpdate > (Date.now() - this._session.typingTimeout)) {
    return Promise.resolve();
  }

  this._lastTypingUpdate = Date.now();
  return this._session.sendTypingIndicator(this._sid);
};

/**
 * Update the Channel's attributes.
 * @param {Object} attributes - The new attributes object.
 * @returns {Promise<Channel>} A Promise for the Channel
 */
Channel.prototype.updateAttributes = function updateAttributes(attributes) {
  if (typeof attributes === 'undefined') {
    throw new Error('Attributes is a required parameter for updateAttributes');
  } else if (attributes.constructor !== Object)  {
    throw new Error('Attributes must be a valid JSON object.');
  }

  return this._session.addCommand('editAttributes', {
    channelSid: this._sid,
    attributes: JSON.stringify(attributes)
  }).then(() => this);
};

/**
 * Update the Channel's friendlyName.
 * @param {String} name - The new Channel friendlyName.
 * @returns {Promise<Channel>} A Promise for the Channel
 */
Channel.prototype.updateFriendlyName = function updateFriendlyName(name) {
  if (this._friendlyName === name) {
    return Promise.resolve(this);
  }

  return this._session.addCommand('editFriendlyName', {
    channelSid: this._sid,
    friendlyName: name
  }).then(() => this);
};

/**
 * Update the Channel's unique name (tag).
 * @param {String} uniqueName - The new Channel uniqueName.
 * @returns {Promise<Channel>} A Promise for the Channel
 */
Channel.prototype.updateUniqueName = function updateUniqueName(uniqueName) {
  if (this._uniqueName === uniqueName) {
    return Promise.resolve(this);
  }

  return this._session.addCommand('editUniqueName', {
    channelSid: this._sid,
    uniqueName: uniqueName
  }).then(() => this);
};

/**
 * Update the Channel's type (public or private). Currently not implemented.
 * @param {String} type
 * @private
 * @returns {Promise<Channel>} A Promise for the Channel
 */
Channel.prototype.updateType = function(type) {
  if (type !== Channel.type.PRIVATE && type !== Channel.type.PUBLIC) {
    throw new Error('Can\'t set unknown channel type ' + type);
  }

  if (this._type !== type) {
    throw new Error('Changing of channel type isn\'t supported');
  }

  return Promise.resolve(this);
};

Object.freeze(Channel);

/**
 * Fired when a Member has joined the Channel.
 * @param {Member} member
 * @event Channel#memberJoined
 */
/**
 * Fired when a Member has left the Channel.
 * @param {Member} member
 * @event Channel#memberLeft
 */
/**
 * Fired when a Member's fields has been updated.
 * @param {Member} member
 * @event Channel#memberUpdated
 */
/**
 * Fired when a new Message has been added to the Channel on the server.
 * @param {Message} message
 * @event Channel#messageAdded
 */
/**
 * Fired when Message is removed from Channel's message list.
 * @param {Message} message
 * @event Channel#messageRemoved
 */
/**
 * Fired when an existing Message's fields are updated with new values.
 * @param {Message} message
 * @event Channel#messageUpdated
 */
/**
 * Fired when a member has stopped typing.
 * @event Channel#typingStarted
 * @type {Member}
 */
/**
 * Fired when a member has begun typing.
 * @event Channel#typingEnded
 * @type {Member}
 */
/**
 * Fired when the Channel's fields have been updated.
 * @param {Channel} channel
 * @event Channel#updated
 */

module.exports = Channel;