Source: room.js

/**
 * @namespace Room
 * @memberOf FlashphonerSFU
 */

const { v4: uuidv4 } = require("uuid");
const constants = require("./constants");
const promises = require("./promise");
const logger = require("./logger");

const room = function(options) {
    if (!options) {
        throw new TypeError("No options provided");
    }
    const room = {};
    let state = constants.SFU_ROOM_STATE.NEW;
    let role = constants.SFU_PARTICIPANT_ROLE.PARTICIPANT;
    let inviteId;
    const callbacks = {};
    const connection = options.connection;
    const pc = options.pc;
    const name = options.name;
    const pin = options.pin;

    let incomingMessageQueue = {};

    //data channel
    const dChannel = options.pc.createDataChannel("control");
    dChannel.onopen = function() {
        logger.log("DataChannel opened");
    };
    dChannel.onclose = function() {
        logger.log("DataChannel closed");
    };
    dChannel.onerror = function(e) {
        //console.error("Got error in data channel ", e);
    };
    dChannel.onmessage = function(msg) {
        logger.log("dchannel ", "<==", msg);
        const message = JSON.parse(msg.data);
        if (message.type === constants.SFU_ROOM_EVENT.MESSAGE && message.message.message.indexOf("\"payload\":") !== -1) {
            try {
                let innerMessage = JSON.parse(message.message.message);
                if (!incomingMessageQueue[innerMessage.id]) {
                    incomingMessageQueue[innerMessage.id] = [];
                }
                incomingMessageQueue[innerMessage.id].push(innerMessage);
                if (innerMessage.last) {
                    let wholeMessage = "";
                    for (let i = 0; i < incomingMessageQueue[innerMessage.id].length; i++) {
                        wholeMessage += incomingMessageQueue[innerMessage.id][i].payload;
                    }
                    delete incomingMessageQueue[innerMessage.id];
                    message.message.message = wholeMessage;
                    notify(message.type, message.message);
                }
            } catch (e) {
                window.log.info("Failed to process inner message: " + message.message);
                notify(message.type, message.message);
            }
        } else if (message.type === constants.SFU_EVENT.ACK && promises.promised(message.internalMessageId)) {
            promises.resolve(message.internalMessageId);
        } else {
            notify(message.type, message.message);
        }
    };

    const dChannelSend = (msg) => {
        logger.log("dchannel ", "==>", msg);
        dChannel.send(msg);
    };

    const processEvent = function(e) {
        logger.log("<==", e);
        if (e.type === constants.SFU_ROOM_EVENT.REMOTE_SDP) {
            if (state !== constants.SFU_ROOM_STATE.FAILED && state !== constants.SFU_ROOM_STATE.DISPOSED) {
                switch (e.info.type) {
                    case "offer":
                        pc.setRemoteDescription(e.info).then(() => pc.createAnswer())
                            .then(answer => pc.setLocalDescription(answer))
                            .then(() => {
                                connection.send(constants.SFU_INTERNAL_API.UPDATE_ROOM_STATE, {
                                    name: name,
                                    pin: pin,
                                    sdp: pc.localDescription.sdp
                                });
                            });
                        break;
                    case "answer":
                        pc.setRemoteDescription(e.info);
                        break;
                }
            }
        } else if (e.type === constants.SFU_ROOM_EVENT.ROLE_ASSIGNED) {
            role = e.role;
            notify(e.type, e);
        } else if (e.type === constants.SFU_ROOM_EVENT.CREATED) {
            inviteId = e.inviteId;
            if (!promises.resolve(e.internalMessageId, e)) {
                notify(e.type, e);
            }
        } else if (e.type === constants.SFU_ROOM_EVENT.OPERATION_FAILED && promises.promised(e.internalMessageId)) {
            promises.reject(e.internalMessageId, e);
        } else if (e.type === constants.SFU_ROOM_EVENT.JOINED) {
            if (!promises.resolve(e.internalMessageId, e)) {
                notify(e.type, e);
            }
        } else if (e.type === constants.SFU_ROOM_EVENT.LEFT) {
            if (!promises.resolve(e.internalMessageId, e)) {
                notify(e.type, e);
            }
        } else if (e.type === constants.SFU_ROOM_EVENT.ADD_TRACKS) {
            if (!promises.resolve(e.internalMessageId, e.info)) {
                notify(e.type, e);
            }
        } else if (e.type === constants.SFU_ROOM_EVENT.REMOVE_TRACKS) {
            if (!promises.resolve(e.internalMessageId, e)) {
                notify(e.type, e);
            }
        } else if (e.type === constants.SFU_ROOM_EVENT.WAITING_ROOM_UPDATE && promises.promised(e.internalMessageId)) {
            promises.resolve(e.internalMessageId, e.enabled);
        } else if (e.type === constants.SFU_EVENT.ACK && promises.promised(e.internalMessageId)) {
            promises.resolve(e.internalMessageId);
        } else {
            notify(e.type, e);
        }
    };

    /**
     * Create room at server side.
     */
    const createRoom = function() {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.CREATE_ROOM, {
                name: name,
                pin: pin,
                internalMessageId: id
            });
        });
    };

    /**
     * Join room.
     * @param {Object=} config Config for track marking. Key is a track id and value is a String (e.g. screen_sharing, camera, front_camera).
     * The specified value will be available for other participants as contentType in {@link FlashphonerSFU.Room.TrackInfo}
     * @memberOf FlashphonerSFU.Room
     */
    const join = function(config) {
        return new Promise((resolve, reject) => {
            if (state === constants.SFU_ROOM_STATE.NEW) {
                state = constants.SFU_ROOM_STATE.JOINED;
                pc.createOffer().then(function (offer) {
                    if (config) {
                        offer.sdp = applyContentTypeConfig(offer.sdp, config);
                    }
                    pc.setLocalDescription(offer).then(function () {
                        const id = uuidv4();
                        promises.add(id, resolve, reject);
                        connection.send(constants.SFU_INTERNAL_API.JOIN_ROOM, {
                            name: name,
                            pin: pin,
                            sdp: offer.sdp,
                            internalMessageId: id
                        });
                    }, reject);
                }, reject);
            } else {
                reject("Can't joined room with state " + state);
            }
        });
    };

    /**
     * Update state after adding tracks to PeerConnection.
     * This method kicks off sdp renegotiation.
     * @param {Object=} config Config for track marking. Key is a track id and value is a String (e.g. screen_sharing, camera, front_camera).
     * The specified value will be available for other participants as contentType in {@link FlashphonerSFU.Room.TrackInfo}
     * @throws {Error} Error if peer connection is being negotiated
     * @memberOf FlashphonerSFU.Room
     */
    const updateState = function(config) {
        return new Promise((resolve, reject) => {
            if (pc.signalingState !== "stable") {
                reject("Peer connection signaling state is " + pc.signalingState + ". Can't update room while negotiation is in progress");
            }
            pc.createOffer().then(function(offer) {
                pc.setLocalDescription(offer).then(function() {
                    if (config) {
                        offer.sdp = applyContentTypeConfig(offer.sdp, config);
                    }
                    const id = uuidv4();
                    promises.add(id, resolve, reject);
                    connection.send(constants.SFU_INTERNAL_API.UPDATE_ROOM_STATE, {
                        name: name,
                        pin: pin,
                        sdp: offer.sdp,
                        internalMessageId: id
                    });
                }, reject);
            }, reject);
        })

    };

    const applyContentTypeConfig = function(sdp, config) {
        let ret = "";
        for (const str of sdp.split("\n")) {
            if (str && str.length > 0) {
                ret += str + "\n";
                if (str.indexOf("a=msid:") > -1) {
                    const msid = str.substring(str.indexOf(" ") + 1).trim();
                    if (config[msid]) {
                        ret += constants.SFU_INTERNAL_API.TRACK_CONTENT_HEADER + config[msid] + "\r\n";
                    }
                }
            }
        }
        return ret;
    };

    /**
     * Destroys room instance at server side.
     * @memberOf FlashphonerSFU.Room
     */
    const destroyRoom = function() {
        return new Promise((resolve, reject) => {
            state = constants.SFU_ROOM_STATE.DISPOSED;
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.DESTROY_ROOM, {
                name: name,
                pin: pin,
                internalMessageId: id
            });
        });
    };

    /**
     * Leave room.
     * @memberOf FlashphonerSFU.Room
     */
    const leaveRoom = function() {
        return new Promise((resolve, reject) => {
            state = constants.SFU_ROOM_STATE.DISPOSED;
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.LEAVE_ROOM, {
                name: name,
                pin: pin,
                internalMessageId: id
            });
            pc.close();
            pc.dispatchEvent(new Event("connectionstatechange"));
        })

    };

    /**
     * Broadcast message to participants currently in the Room.
     * This method will use DataChannels.
     * @param {String} msg
     * @memberOf FlashphonerSFU.Room
     */
    const sendMessage = function(msg) {
        return new Promise((resolve, reject) => {
            //throttle messages
            const id = uuidv4();
            /**
             * Note that in case of chunked message promise will be resolved only after sending last message (last: true)
             */
            promises.add(id, resolve, reject);
            const chunkSize = 16384;
            if (msg.length > chunkSize) {
                const chunks = msg.match(new RegExp("(.|[\r\n]){1,"+chunkSize+"}", "g"));
                for (let i = 0; i < chunks.length; i++) {
                    dChannelSend(JSON.stringify({
                        id: id,
                        last: i === chunks.length - 1,
                        payload: chunks[i]
                    }));
                }
            } else {
                dChannelSend(JSON.stringify({
                    id: id,
                    payload: msg
                }));
            }
        })

    };

    /**
     * Send control message.
     * This method will use WSS to send the message.
     * @param {String} to recipient's nickname
     * @param {String} msg message
     * @param {Boolean} broadcast ignore 'to' and broadcast the message (send it to all participants)
     */
    const sendControlMessage = function(to, msg, broadcast) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.SEND_CONTROL_MESSAGE, {
                broadcast: broadcast,
                from: "",
                to: to,
                body: msg,
                internalMessageId: id
            });
        })
    };

    /**
     * Change receiving quality of the simulcast track.
     * @param {String} trackId Id of the track
     * @param {String} quality one of qualities advertised in {@link FlashphonerSFU.Room.TrackInfo}
     * @param {String} tid In some tracks (Such as WebRTC simulcast VP8 track) there is an option of changing FPS of the
     * track by changing TID. At the time of writing there were 3 TIDs (lower TID = lower FPS) 0|1|2
     * @memberOf FlashphonerSFU.Room
     */
    const changeQuality = function(trackId, quality, tid) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send("changeQuality", {
                roomName: name,
                id: trackId,
                quality: quality,
                tid: tid,
                internalMessageId: id
            });
        })
    };

    /**
     * Authorize user that is currently resides in waiting room. Note that this will only work with {@link FlashphonerSFUExtended}
     * @param {String} userId User id
     * @param {Boolean} authorized If true participant will move to the main room otherwise participant will be evicted.
     * @memberOf FlashphonerSFU.Room
     */
    const authorizeWaitingList = function(userId, authorized) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.AUTHORIZE_WAITING_LIST, {
                name: name,
                userId: userId,
                authorized: authorized,
                internalMessageId: id
            });
        })

    };

    /**
     * Move user from room into waiting room. Note that this will only work with {@link FlashphonerSFUExtended}
     * @param {String} nickname Participant nickname
     * @memberOf FlashphonerSFU.Room
     */
    const moveToWaitingRoom = function(nickname) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.MOVE_TO_WAITING_ROOM, {
                roomName: name,
                nickname: nickname,
                internalMessageId: id
            });
        });
    };

    /**
     * Turn waiting room on/off
     * @param {Boolean} enabled Boolean flag
     * @memberOf FlashphonerSFU.Room
     */
    const configureWaitingRoom = function(enabled) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.CONFIGURE_WAITING_ROOM, {
                name: name,
                enabled: enabled,
                internalMessageId: id
            });
        });
    };

    /**
     * Mute track. Mute takes place at server side - server stops forwarding this track.
     * @param {String} trackId Id of the track
     * @param {Boolean} mute Mute flag
     * @memberOf FlashphonerSFU.Room
     */
    const muteTrack = function(trackId, mute) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.MUTE_TRACK, {
                roomName: name,
                id: trackId,
                mute: mute
            });
        })
    };

    /**
     * Assign user a role. Note that this will only work with {@link FlashphonerSFUExtended}
     * @param {String} nickname Nickname of the participant
     * @param {FlashphonerSFU.SFU_PARTICIPANT_ROLE} role Role to assign
     * @memberOf FlashphonerSFU.Room
     */
    const assignRole = function(nickname, role) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.ASSIGN_ROLE, {
                roomName: name,
                nickname: nickname,
                role: role,
                internalMessageId: id
            });
        })
    };

    /**
     * Subscribe to tracks of participant that is currently resides in waiting room.
     * New tracks will have `info.waitingRoom` flag set to true, see {@link FlashphonerSFU.Room.TracksInfo}.
     * Note that this will only work with {@link FlashphonerSFUExtended}
     * @param {String} nickname Nickname of the participant
     * @memberOf FlashphonerSFU.Room
     */
    const subscribeToWaitingParticipant = function(nickname) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.SUBSCRIBE_TO_WAITING_PARTICIPANT, {
                roomName: name,
                nickname: nickname,
                internalMessageId: id
            });
        })
    };

    /**
     * Unsubscribe from tracks of participant that is currently resides in waiting room.
     * Note that this will only work with {@link FlashphonerSFUExtended}
     * @param {String} nickname Nickname of the participant
     * @memberOf FlashphonerSFU.Room
     */
    const unsubscribeFromWaitingParticipant = function(nickname) {
        return new Promise((resolve, reject) => {
            const id = uuidv4();
            promises.add(id, resolve, reject);
            connection.send(constants.SFU_INTERNAL_API.UNSUBSCRIBE_FROM_WAITING_PARTICIPANT, {
                roomName: name,
                nickname: nickname,
                internalMessageId: id
            });
        })

    };

    /**
     * Room event callback.
     *
     * @callback FlashphonerSFU.Room~eventCallback
     * @param {FlashphonerSFU.Room} sdk instance FlashphonerSFU.Room
     */

    /**
     * Add room event callback.
     *
     * @param {String} event One of {@link FlashphonerSFU.SFU_ROOM_EVENT} events
     * @param {FlashphonerSFU.Room~eventCallback} callback Callback function
     * @returns {FlashphonerSFU.Room} SDK instance callback was attached to
     * @throws {TypeError} Error if event is not specified
     * @throws {Error} Error if callback is not a valid function
     * @memberOf FlashphonerSFU.Room
     */
    const on = function (event, callback) {
        if (!event) {
            throw new TypeError("Event can't be null");
        }
        if (!callback || typeof callback !== "function") {
            throw new Error("Callback needs to be a valid function");
        }
        if (!callbacks[event]) {
            callbacks[event] = [];
        }
        callbacks[event].push(callback);
        return room;
    };

    /**
     * Remove room event callback.
     *
     * @param {String} event One of {@link FlashphonerSFU.SFU_ROOM_EVENT} events
     * @param {FlashphonerSFU.Room~eventCallback} callback Callback function
     * @returns {FlashphonerSFU.Room} SDK instance callback was removed from
     * @throws {TypeError} Error if event is not specified
     * @throws {Error} Error if callback is not a valid function
     * @memberOf FlashphonerSFU.Room
     */
    const off = function (event, callback) {
        if (!event) {
            throw new TypeError("Event can't be null");
        }
        if (!callback || typeof callback !== "function") {
            throw new Error("Callback needs to be a valid function");
        }
        if (!callbacks[event]) {
            callbacks[event] = [];
        }
        let index = callbacks[event].findIndex(element => element === callback);
        if (index !== -1) {
            console.log("off ", event);
            callbacks[event] = callbacks[event].slice(index, 1);
        }
        return room;
    };

    const notify = function(event, msg) {
        if (callbacks[event]) {
            for (const callback of callbacks[event]) {
                callback(msg);
            }
        }
    };

    room.processEvent = processEvent;
    room.createRoom = createRoom;//done
    room.join = join;//done
    room.updateState = updateState;//done
    room.destroyRoom = destroyRoom;//done
    room.leaveRoom = leaveRoom;//done
    room.sendMessage = sendMessage;//done
    room.sendControlMessage = sendControlMessage;//done
    room.changeQuality = changeQuality;//done
    room.authorizeWaitingList = authorizeWaitingList;//done
    room.muteTrack = muteTrack;//done
    room.assignRole = assignRole;//done
    room.subscribeToWaitingParticipant = subscribeToWaitingParticipant;//done
    room.unsubscribeFromWaitingParticipant = unsubscribeFromWaitingParticipant;//done
    room.moveToWaitingRoom = moveToWaitingRoom;//done
    room.configureWaitingRoom = configureWaitingRoom;//done
    /**
     * Room name
     * @returns {String} room name
     */
    room.name = function() {
        return name;
    };
    /**
     * Room pin
     * @returns {String} room pin
     */
    room.pin = function() {
        return pin;
    };

    /**
     * Room underlying PeerConnection
     * @returns {RTCPeerConnection} peer connection
     */
    room.pc = function() {
        return pc;
    };
    /**
     * Local user role
     * @returns {FlashphonerSFU.SFU_PARTICIPANT_ROLE}
     */
    room.role = function() {
        return role;
    };
    /**
     * HTTP address of the invite. Note that this will only work with {@link FlashphonerSFUExtended}
     * @returns {String}
     */
    room.invite = function() {
        return inviteId;
    };
    room.on = on;
    room.off = off;

    return room;
};


module.exports = {
    room: room
};