chat-engine/src/components/chat.js


const waterfall = require('async/waterfall');
const Emitter = require('../modules/emitter');
const Event = require('../components/event');
const Search = require('../components/search');

const augmentChat = require('../plugins/augment/chat');

/**
 This is the root {@link Chat} class that represents a chat room

 @param {String} [channel=new Date().getTime()] A unique identifier for this chat {@link Chat}. Must be alphanumeric. The channel is the unique name of a {@link Chat}, and is usually something like "The Watercooler", "Support", or "Off Topic". See [PubNub Channels](https://support.pubnub.com/support/solutions/articles/14000045182-what-is-a-channel-).
 @param {Boolean} [isPrivate=false] Attempt to authenticate ourselves before connecting to this {@link Chat}.
 @param {Boolean} [autoConnect=true] Connect to this chat as soon as its initiated. If set to ```false```, call the {@link Chat#connect} method to connect to this {@link Chat}.
 @param {Object} [meta={}] Chat metadata that will be persisted on the server and populated on creation.
 @param {String} [group='default'] Groups chat into a "type". This is the key which chats will be grouped into within {@link Me.session} object.
 @class Chat
 @extends Emitter
 @extends RootEmitter
 @fires Chat#$"."ready
 @fires Chat#$"."state
 @fires Chat#$"."online"."*
 @fires Chat#$"."offline"."*
 */
class Chat extends Emitter {

    constructor(chatEngine, channel = new Date().getTime(), isPrivate = false, autoConnect = true, meta = {}, group = 'custom') {

        super(chatEngine);

        this.chatEngine = chatEngine;

        this.name = 'Chat';

        this.plugin(augmentChat(this));

        /**
        * Classify the chat within some group, Valid options are 'system', 'fixed', or 'custom'.
        * @type Boolean
        * @readonly
        * @private
        */
        this.group = group;

        /**
        * Excludes all users from reading or writing to the {@link chat} unless they have been explicitly invited via {@link Chat#invite};
        * @type Boolean
        * @readonly
        */
        this.isPrivate = isPrivate;

        /**
         * Chat metadata persisted on the server. Useful for storing things like the name and description. Call {@link Chat#update} to update the remote information.
         * @type Object
         * @readonly
         */
        this.meta = meta || {};

        /**
         * A string identifier for the Chat room. Any chat with an identical channel will be able to communicate with one another.
         * @type String
         * @readonly
         * @see [PubNub Channels](https://support.pubnub.com/support/solutions/articles/14000045182-what-is-a-channel-)
         */
        this.channel = this.chatEngine.augmentChannel(channel, this.isPrivate);

        /**
         A list of users in this {@link Chat}. Automatically kept in sync as users join and leave the chat.
         Use [$.join](/Chat.html#event:$%2522.%2522join) and related events to get notified when this changes

         @type Object
         @readonly
         */
        this.users = {};

        /**
         * Boolean value that indicates of the Chat is connected to the network
         * @type {Boolean}
         */
        this.connected = false;

        /**
         * Keep a record if we've every successfully connected to this chat before.
         * @type {Boolean}
         */
        this.hasConnected = false;

        /**
         * If user manually disconnects via {@link ChatEngine#disconnect}, the
         * chat is put to "sleep". If a connection is reestablished
         * via {@link ChatEngine#reconnect}, sleeping chats reconnect automatically.
         * @type {Boolean}
         */
        this.asleep = false;

        this.chatEngine.chats[this.channel] = this;

        if (autoConnect) {
            this.connect();
        }

        return this;

    }

    /**
     Updates list of {@link User}s in this {@link Chat}
     based on who is online now.

     @private
     @param {Object} status The response status
     @param {Object} response The response payload object
     */
    onHereNow(status, response) {

        if (status.error) {

            /**
             * There was a problem fetching the presence of this chat
             * @event Chat#$"."error"."presence
             * @property {String} ceError The specific error thrown by ChatEngine
             * @property {Number} status The PubNub service that surfaced this error
             * @property {String} error
             * @property {String} message The PubNub supplied error
             * @property {Object} payload
             */
            this.chatEngine.throwError(this, 'trigger', 'presence', new Error('Getting presence of this Chat. Make sure PubNub presence is enabled for this key'));

        } else {

            // get the list of occupants in this channel
            let occupants = response.channels[this.channel].occupants;

            // format the userList for rltm.js standard
            occupants.forEach((occupant) => {
                this.userUpdate(occupant.uuid, occupant.state);
            });

        }

    }

    /**
    * Turns a {@link Chat} into a JSON representation.
    * @return {Object}
    */
    objectify() {

        return {
            channel: this.channel,
            group: this.group,
            private: this.isPrivate,
            meta: this.meta
        };

    }

    /**
     * Invite a user to this Chat. Authorizes the invited user in the Chat and sends them an invite via {@link User#direct}.
     * @param {User} user The {@link User} to invite to this chatroom.
     * @fires Me#event:$"."invite
     * @example
     * // one user running ChatEngine
     * let secretChat = new ChatEngine.Chat('secret-channel');
     * secretChat.invite(someoneElse);
     *
     * // someoneElse in another instance of ChatEngine
     * me.direct.on('$.invite', (payload) => {
     *     let secretChat = new ChatEngine.Chat(payload.data.channel);
     * });
     */
    invite(user) {

        this.chatEngine.request('post', 'invite', {
            to: user.uuid,
            chat: this.objectify()
        })
            .then(() => {

                let send = () => {

                    /**
                     * Notifies {@link Me} that they've been invited to a new private {@link Chat}.
                     * Fired by the {@link Chat#invite} method.
                     * @event Me#$"."invite
                     * @type {object}
                     * @property {Chat} chat The chat the event was emitted on. This is the {@link User}'s direct chat and NOT the chat invited to.
                     * @property {string} chatengineSDK The ChatEngine client version number.
                     * @property {object} data The message payload data.
                     * @property {string} data.channel The {@link Chat#channel} for the invited chat. Use this to create a new {@link Chat}.
                     * @property {string} event The event fired.
                     * @property {User} sender The {@link User} that sent the invite.
                     * @property {string} timetoken The 17 digit timetoken when the message was sent.
                     * @tutorial private
                     * @example
                     * me.direct.on('$.invite', (payload) => {
                     *    let privChat = new ChatEngine.Chat(payload.data.channel));
                     * });
                     */
                    user.direct.emit('$.invite', {
                        channel: this.channel
                    });

                };

                if (!user.direct.connected) {
                    user.direct.connect();
                    user.direct.on('$.connected', send);
                } else {
                    send();
                }

            })
            .catch((error) => {

                /**
                 * Something went wrong with the server request to invite the user.
                 * @event Chat#$"."error"."invite
                 * @property {String} ceError The specific error thrown by ChatEngine
                 */
                this.chatEngine.throwError(this, 'trigger', 'invite', new Error('Something went wrong while making a request to authentication server.'), { error });
            });

    }

    /**
     Keep track of {@link User}s in the room by subscribing to PubNub presence events.

     @private
     @param {Object} data The PubNub presence response for this event
     */
    onPresence(presenceEvent) {

        // make sure channel matches this channel

        // someone joins channel
        if (presenceEvent.action === 'join') {
            this.userJoin(presenceEvent.uuid, presenceEvent.state);
        }

        // someone leaves channel
        if (presenceEvent.action === 'leave') {
            this.userLeave(presenceEvent.uuid);
        }

        // someone timesout
        if (presenceEvent.action === 'timeout') {
            this.userDisconnect(presenceEvent.uuid);
        }

        // someone's state is updated
        if (presenceEvent.action === 'state-change') {
            this.userUpdate(presenceEvent.uuid, presenceEvent.state);
        }

    }

    /**
     * Update the {@link Chat} metadata on the server.
     * @param  {object} data JSON object representing chat metadta.
     */
    update(data) {

        let oldMeta = this.meta || {};
        this.meta = Object.assign(oldMeta, data);

        this.chatEngine.request('post', 'chat', {
            chat: this.objectify()
        }).then(() => {
        }).catch((error) => {

            /**
             * Something went wrong updating the chat metadata.
             * @event Chat#$"."error"."chatMeta
             * @property {String} ceError The specific error thrown by ChatEngine
             */
            this.chatEngine.throwError(this, 'trigger', 'chatMeta', new Error('Something went wrong while making a request to chat server.'), { error });
        });

    }

    /**
     * Send events to other clients in this {@link User}.
     * Events are trigger over the network  and all events are made
     * on behalf of {@link Me}
     *
     * @param {String} event The event name
     * @param {Object} data The event payload object
     * @example
     * chat.emit('custom-event', {value: true});
     * chat.on('custom-event', (payload) => {
      *     console.log(payload.sender.uuid, 'emitted the value', payload.data.value);
      * });
     */
    emit(event, data) {

        if (event === 'message' && typeof data !== 'object') {
            throw new Error('the payload has to be an object');
        }

        // create a standardized payload object
        let payload = {
            data, // the data supplied from params
            sender: this.chatEngine.me.uuid, // my own uuid
            chat: this, // an instance of this chat
            event,
            chatengineSDK: this.chatEngine.package.version
        };

        let tracer = new Event(this.chatEngine, this, event);

        // run the plugin queue to modify the event
        this.runPluginQueue('emit', event, (next) => {
            next(null, payload);
        }, (err, pluginResponse) => {

            // remove chat otherwise it would be serialized
            // instead, it's rebuilt on the other end.
            // see this.trigger
            delete pluginResponse.chat;

            // publish the event and data over the configured channel
            tracer.publish(pluginResponse);

        });

        return tracer;

    }

    /**
     Add a user to the {@link Chat}, creating it if it doesn't already exist.

     @private
     @param {String} uuid The user uuid
     @param {Object} state The user initial state
     @param {Boolean} trigger Force a trigger that this user is online
     */
    userJoin(uuid, state) {

        // Ensure that this user exists in the global list
        // so we can reference it from here out
        this.chatEngine.users[uuid] = this.chatEngine.users[uuid] || new this.chatEngine.User(uuid);
        this.chatEngine.users[uuid].assign(state);

        // check if the user already exists within the chatroom
        // so we know if we need to notify or not
        let userAlreadyHere = this.users[uuid];

        // assign the user to the chatroom
        this.users[uuid] = this.chatEngine.users[uuid];

        // trigger the join event over this chatroom
        if (userAlreadyHere) {

            /**
             * Broadcast that a {@link User} has come online. This is when
             * the framework firsts learn of a user. This can be triggered
             * by, ```$.join```, or other network events that
             * notify the framework of a new user.
             *
             * @event Chat#$"."online"."here
             * @property {User} user The {@link User} that came online
             * @example
             * chat.on('$.online.here', (data) => {
             *     console.log('User has come online:', data.user);
             * });
             */

            this.trigger('$.online.here', { user: this.users[uuid] });

        } else {

            /**
             * Fired when a {@link User} has joined the room.
             *
             * @event Chat#$"."online"."join
             * @property {Object} data The payload returned by the event
             * @property {User} data.user The {@link User} that came online
             * @example
             * chat.on('$.join', (data) => {
             *     console.log('User has joined the room!', data.user);
             * });
             */

            this.trigger('$.online.join', { user: this.users[uuid] });

        }

        // return the instance of this user
        return this.chatEngine.users[uuid];

    }

    /**
     * Update a user's state.
     * @private
     * @param {String} uuid The {@link User} uuid
     * @param {Object} state State to update for the user
     */
    userUpdate(uuid, state) {

        // ensure the user exists within the global space
        this.chatEngine.users[uuid] = this.chatEngine.users[uuid] || new this.chatEngine.User(uuid);

        // if we don't know about this user
        if (!this.users[uuid]) {
            // do the whole join thing
            this.userJoin(uuid, state);
        }

        // update this user's state in this chatroom
        this.users[uuid].assign(state);

        /**
         * Broadcast that a {@link User} has changed state.
         * @event ChatEngine#$"."state
         * @property {User} user The {@link User} that changed state
         * @property {Object} state The new user state
         * @example
         * ChatEngine.on('$.state', (data) => {
         *     console.log('User has changed state:', data.user, 'new state:', data.state);
         * });
         */
        this.chatEngine._emit('$.state', {
            user: this.users[uuid],
            state: this.users[uuid].state
        });

    }

    /**
     * Called by {@link ChatEngine#disconnect}. Fires disconnection notifications
     * and stores "sleep" state in memory. Sleep means the Chat was previously connected.
     * @private
     */
    sleep() {

        if (this.connected) {
            this.onDisconnected();
            this.asleep = true;
        }
    }

    /**
     * Called by {@link ChatEngine#reconnect}. Wakes the Chat up from sleep state.
     * Re-authenticates with the server, and fires connection events once established.
     * @private
     */
    wake() {

        if (this.asleep) {
            this.handshake(() => {
                this.onConnected();
            });
        }

    }

    /**
     * Fired upon successful connection to the network.
     * @private
     */
    onConnected() {
        this.connected = true;
        this.trigger('$.connected');
    }

    /**
     * Fires upon disconnection from the network through any means.
     * @private
     */
    onDisconnected() {
        this.connected = false;
        this.trigger('$.disconnected');
    }
    /**
     * Fires upon manually invoked leaving.
     * @private
     */
    onLeave() {
        this.trigger('$.left');
        this.onDisconnected();
    }

    /**
     * Leave from the {@link Chat} on behalf of {@link Me}. Disconnects from the {@link Chat} and will stop
     * receiving events.
     * @fires Chat#event:$"."offline"."leave
     * @example
     * chat.leave();
     */
    leave() {

        // unsubscribe from the channel locally
        this.chatEngine.pubnub.unsubscribe({
            channels: [this.channel]
        });

        // tell the server we left
        this.chatEngine.request('post', 'leave', { chat: this.objectify() })
            .then(() => {

                // trigger the disconnect events and update state
                this.onLeave();

                // tell the chat we've left
                this.emit('$.system.leave', { subject: this.objectify() });

                // tell session we've left
                if (this.chatEngine.me.session) {
                    this.chatEngine.me.session.leave(this);
                }

            })
            .catch((error) => {

                /**
                 * There was a problem leaving the chat.
                 * @event Chat#$"."error"."chatLeave
                 * @property {String} ceError The specific error thrown by ChatEngine
                 */
                this.chatEngine.throwError(this, 'trigger', 'chatLeave', new Error('Something went wrong while making a request to chat server.'), { error });

            });

    }

    /**
     Perform updates when a user has left the {@link Chat}.

     @private
     */
    userLeave(uuid) {

        // store a temporary reference to send with our event
        let user = this.users[uuid];

        // remove the user from the local list of users
        // we don't remove the user from the global list,
        // because they may be online in other channels
        delete this.users[uuid];

        // make sure this event is real, user may have already left
        if (user) {

            // if a user leaves, trigger the event

            /**
             * Fired when a {@link User} intentionally leaves a {@link Chat}.
             *
             * @event Chat#$"."offline"."leave
             * @property {User} user The {@link User} that has left the room
             * @example
             * chat.on('$.offline.leave', (data) => {
                      *     console.log('User left the room manually:', data.user);
                      * });
             */
            this.trigger('$.offline.leave', { user });

        }

    }

    /**
     Fired when a user disconnects from the {@link Chat}

     @private
     @param {String} uuid The uuid of the {@link Chat} that left
     */
    userDisconnect(uuid) {

        let user = this.users[uuid];
        delete this.users[uuid];

        // make sure this event is real, user may have already left
        if (user) {

            /**
             * Fired specifically when a {@link User} looses network connection
             * to the {@link Chat} involuntarily.
             *
             * @event Chat#$"."offline"."disconnect
             * @property {Object} user The {@link User} that disconnected
             * @example
             * chat.on('$.offline.disconnect', (data) => {
             *     console.log('User disconnected from the network:', data.user);
             * });
             */
            this.trigger('$.offline.disconnect', { user });

        }

    }

    /**
     Set the state for {@link Me} within this {@link User}.
     Broadcasts the ```$.state``` event on other clients

     @private
     @param {Object} state The new state {@link Me} will have within this {@link User}
     */
    setState(state, callback) {
        this.chatEngine.pubnub.setState({ state, channels: [this.chatEngine.global.channel] }, callback);
    }

    /**
     Search through previously emitted events. Parameters act as AND operators. Returns an instance of the emitter based {@link History}. Will
     which will emit all old events unless ```config.event``` is supplied.
     @param {Object} [config] Our configuration for the PubNub history request. See the [PubNub History](https://www.pubnub.com/docs/web-javascript/storage-and-history) docs for more information on these parameters.
     @param {Event} [config.event] The {@link Event} to search for.
     @param {User} [config.sender] The {@link User} who sent the message.
     @param {Number} [config.pages=10] The maximum number of history requests which {@link ChatEngine} will do automatically to fulfill `limit` requirement.
     @param {Number} [config.limit=20] The maximum number of results to return that match search criteria. Search will continue operating until it returns this number of results or it reached the end of history. Limit will be ignored in case if both 'start' and 'end' timetokens has been passed in search configuration.
     @param {Number} [config.count=100] The maximum number of messages which can be fetched with single history request.
     @param {Number} [config.start=0] The timetoken to begin searching between.
     @param {Number} [config.end=0] The timetoken to end searching between.
     @param {Boolean} [config.reverse=false] Search oldest messages first.
     @return {Search}
     @example
    chat.search({
        event: 'my-custom-event',
        sender: ChatEngine.me,
        limit: 20
    }).on('my-custom-event', (event) => {
        console.log('this is an old event!', event);
    }).on('$.search.finish', () => {
        console.log('we have all our results!')
    });
     */
    search(config) {

        if (this.hasConnected) {
            return new Search(this.chatEngine, this, config);
        } else {

            /**
             * There was a problem searching the chat.
             * @event Chat#$"."error"."notConnected
             * @property {String} ceError The specific error thrown by ChatEngine
             */
            this.chatEngine.throwError(this, 'trigger', 'notConnected', new Error('You must wait for the $.connected event before calling Chat#search'));
        }

    }

    /**
     * Fired when the chat first connects to network.
     * @private
     */
    connectionReady() {

        this.connected = true;
        this.hasConnected = true;

        /**
         * Broadcast that the {@link Chat} is connected to the network.
         * @event Chat#$"."connected
         * @example
         * chat.on('$.connected', () => {
         *     console.log('chat is ready to go!');
         * });
         */
        this.onConnected();

        if (this.chatEngine.me.session) {
            this.chatEngine.me.session.join(this);
        }

        // add self to list of users
        this.users[this.chatEngine.me.uuid] = this.chatEngine.me;

        // trigger my own online event
        this.trigger('$.online.join', {
            user: this.chatEngine.me
        });

        // global channel updates are triggered manually, only get presence on custom chats
        if (this.channel !== this.chatEngine.global.channel && this.group === 'custom') {

            this.getUserUpdates();

            // we may miss updates, so call this again 5 seconds later
            setTimeout(() => {
                this.getUserUpdates();
            }, 5000);

        }

        this.on('$.system.leave', (payload) => {
            this.userLeave(payload.sender.uuid);
        });

    }

    /**
     * Ask PubNub for information about {@link User}s in this {@link Chat}.
     * @private
     */
    getUserUpdates() {

        // get a list of users online now
        // ask PubNub for information about connected users in this channel
        this.chatEngine.pubnub.hereNow({
            channels: [this.channel],
            includeUUIDs: true,
            includeState: true
        }, (s, r) => {
            this.onHereNow(s, r);
        });

    }

    /**
     * Establish authentication with the server, then subscribe with PubNub.
     * @fires Chat#$"."ready
     * @example
     * // create a new chatroom, but don't connect to it automatically
     * let chat = new Chat('some-chat', false, false);
     *
     * // connect to the chat when we feel like it
     * chat.connect();
     */
    connect() {

        // establish good will with the server
        this.handshake(() => {

            // now that we've got connection, do everything else via connectionReady
            this.connectionReady();

        });

    }

    /**
     * Connect to PubNub servers to initialize the chat.
     * @private
     */
    handshake(complete) {

        waterfall([
            (next) => {
                if (!this.chatEngine.pubnub) {
                    next('You must call ChatEngine.connect() and wait for the $.ready event before creating new Chats.');
                } else {
                    next();
                }
            },
            (next) => {

                this.chatEngine.request('post', 'grant', { chat: this.objectify() })
                    .then(() => {
                        next();
                    })
                    .catch(next);

            },
            (next) => {

                this.chatEngine.request('post', 'join', { chat: this.objectify() })
                    .then(() => {
                        next();
                    })
                    .catch(next);

            },
            (next) => {


                if (this.chatEngine.ceConfig.enableMeta) {

                    this.chatEngine.request('get', 'chat', {}, { channel: this.channel })
                        .then((response) => {

                            // asign metadata locally
                            if (response.data.found) {
                                this.meta = response.data.chat.meta;
                            } else {
                                this.update(this.meta);
                            }

                            next();

                        })
                        .catch(next);

                } else {
                    next();
                }

            }
        ], (error) => {

            if (error) {

                /**
                 * There was a problem searching the chat.
                 * @event Chat#$"."error"."auth
                 * property {String} ceError The specific error thrown by ChatEngine
                 */
                this.chatEngine.throwError(this, 'trigger', 'auth', new Error('Something went wrong while making a request to authentication server.'), { error });

            } else {
                complete();
            }
        });

    }

}

module.exports = Chat;