'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;