UNPKG

nodegame-widgets

Version:

Collections of useful and reusable javascript / HTML snippets for nodeGame

864 lines (769 loc) 27.3 kB
/** * # Chat * Copyright(c) 2021 Stefano Balietti * MIT Licensed * * Creates a simple configurable chat * * // TODO: add bootstrap badge to count msg when collapsed * // TODO: check on data if message comes back * // TODO: highlight better incoming msg. Play sound? * // TODO: removeParticipant and addParticipant methods. * * www.nodegame.org */ (function(node) { "use strict"; var NDDB = node.NDDB; node.widgets.register('Chat', Chat); // ## Texts. Chat.texts = { outgoing: function(w, data) { return data.msg; // return '<span class="chat_msg_me">' + data.msg + '</span>'; }, incoming: function(w, data) { var str; str = '<span>'; if (w.recipientsIds.length > 1) { str += '<span class="chat_id_other">' + (w.senderToNameMap[data.id] || data.id) + '</span>: '; } str += data.msg + '</span>'; return str; }, quit: function(w, data) { return (w.senderToNameMap[data.id] || data.id) + ' left the chat'; }, noMoreParticipants: function() { return 'No active participant left. Chat disabled.'; }, // For both collapse and uncollapse. collapse: function(w, data) { return (w.senderToNameMap[data.id] || data.id) + ' ' + (data.collapsed ? 'mini' : 'maxi') + 'mized the chat'; }, textareaPlaceholder: function(w) { return w.useSubmitEnter ? 'Type something and press enter to send' : 'Type something'; }, submitButton: 'Send', isTyping: 'is typing...' }; // ## Meta-data Chat.version = '1.5.0'; Chat.description = 'Offers a uni-/bi-directional communication interface ' + 'between players, or between players and the server.'; Chat.title = 'Chat'; Chat.className = 'chat'; Chat.panel = false; // ## Dependencies Chat.dependencies = { JSUS: {} }; /** * ## Chat constructor * * `Chat` is a simple configurable chat * * @see Chat.init */ function Chat() { /** * ### Chat.chatEvent * * The suffix used to fire chat events * * Default: 'CHAT' */ this.chatEvent = null; /** * ### Chat.stats * * Some basic statistics about message counts */ this.stats = { received: 0, sent: 0, unread: 0 }; /** * ### Chat.submitButton * * Button to send a text to server * * @see Chat.useSubmitButton */ this.submitButton = null; /** * ### Chat.useSubmitButton * * If TRUE, a button is added to send messages * * By default, this is TRUE on mobile devices. * * @see Chat.submitButton * @see Chat.receiverOnly */ this.useSubmitButton = null; /** * ### Chat.useSubmitButton * * If TRUE, pressing ENTER sends the msg * * By default, TRUE * * @see Chat.submitButton * @see Chat.receiverOnly */ this.useSubmitEnter = null; /** * ### Chat.receiverOnly * * If TRUE, users cannot send messages (no textarea and submit button) * * @see Chat.textarea */ this.receiverOnly = false; /** * ### Chat.storeMsgs * * If TRUE, a copy of sent and received messages is stored in db * * @see Chat.db */ this.storeMsgs = false; /** * ### Chat.db * * An NDDB database for storing incoming and outgoing messages * * @see Chat.storeMsgs */ this.db = null; /** * ### Chat.chatDiv * * The DIV wherein to display the chat */ this.chatDiv = null; /** * ### Chat.textarea * * The textarea wherein to write and read */ this.textarea = null; /** * ### Chat.initialMsg * * An object with an initial msg and the id of sender (if not self) * * Example: * * ``` * { * id: '1234', // Optional, add only this is an 'incoming' msg. * msg: 'the text' * } */ this.initialMsg = null; /** * ### Chat.recipientsIds * * Array of ids of current recipients of messages */ this.recipientsIds = null; /** * ### Chat.recipientsIdsQuitted * * Array of ids of recipients that have previously quitted the chat */ this.recipientsIdsQuitted = null; /** * ### Chat.senderToNameMap * * Map sender id (msg.from) to display name * * Note: The 'from' field of a message can be different * from the 'to' field of its reply (e.g., for MONITOR) */ this.senderToNameMap = null; /** * ### Chat.recipientToNameMap * * Map recipient id (msg.to) to display name */ this.recipientToNameMap = null; /** * ### Chat.senderToRecipientMap * * Map sender id (msg.from) to recipient id (msg.to) */ this.senderToRecipientMap = null; /** * ### Chat.recipientToSenderMap * * Map recipient id (msg.to) to sender id (msg.from) */ this.recipientToSenderMap = null; /** * ### Chat.showIsTyping * * TRUE, if "is typing" notice is shown */ this.showIsTyping = null; /** * ### Chat.amTypingTimeout * * Timeout to send an own "isTyping" notification * * Timeout is added as soon as the user start typing, cleared when * a message is sent. */ this.amTypingTimeout = null; /** * ### Chat.isTypingTimeouts * * Object containing timeouts for all participants currently typing * * A new timeout is added when an IS_TYPING msg is received and * cleared when a msg arrives or at expiration. */ this.isTypingTimeouts = {}; /** * ### Chat.isTypingDivs * * Object containing divs where "is typing" is diplayed * * Once created */ this.isTypingDivs = {}; /** * ### Chat.preprocessMsg * * A function that process the msg before being displayed * * It does not preprocess the initial message * and "is typing" notifications. * * Example: * * ```js * function(data, code) { * data.msg += '!'; * } * ``` */ this.preprocessMsg = null; } // ## Chat methods /** * ### Chat.init * * Initializes the widget * * @param {object} opts Optional. Configuration options. * * The options object can have the following attributes: * - `receiverOnly`: If TRUE, no message can be sent * - `chatEvent`: The event to fire when sending/receiving a message * - `useSubmitButton`: If TRUE, a submit button is added. * Default: TRUE on mobile * - `useSubmitEnter`: If TRUE, pressing ENTER sends a msg. * Default: TRUE * - `showIsTyping: If TRUE, a notice is displayed when users are * typing. Default: TRUE * - `storeMsgs`: If TRUE, a copy of every message is stored in * a local db * - `participants`: An array containing the ids of participants, * cannot be empty * - `initialMsg`: Initial message to be displayed as soon as the chat * is opened. * - `uncollapseOnMsg`: If TRUE, a minimized chat will automatically * open when receiving a msg. Default: FALSE. * - `printStartTime`: If TRUE, the initial time of the chat is * printed at the beginning of the chat. Default: FALSE. * - `printNames`: If TRUE, the names of the participants of the chat * is printed at the beginning of the chat. Default: FALSE. */ Chat.prototype.init = function(opts) { var tmp, i, rec, sender, that; opts = opts || {}; that = this; // Receiver Only. this.receiverOnly = !!opts.receiverOnly; tmp = opts.preprocessMsg; if ('function' === typeof tmp) { this.preprocessMsg = tmp; } else if (tmp) { throw new TypeError('Chat.init: preprocessMsg must be function ' + 'or undefined. Found: ' + tmp); } // Chat id. tmp = opts.chatEvent; if (tmp) { if ('string' !== typeof tmp) { throw new TypeError('Chat.init: chatEvent must be a non-' + 'empty string or undefined. Found: ' + tmp); } this.chatEvent = opts.chatEvent; } else { this.chatEvent = 'CHAT'; } // Store. this.storeMsgs = !!opts.storeMsgs; if (this.storeMsgs) { if (!this.db) this.db = new NDDB(); } // Button to send msg. this.useSubmitButton = 'undefined' === typeof opts.useSubmitButton ? J.isMobileAgent() : !!opts.useSubmitButton; // Enter to send msg (does not exclude button). this.useSubmitEnter = 'undefined' === typeof opts.useSubmitEnter ? true : !!opts.useSubmitEnter; // Participants. tmp = opts.participants; if (!J.isArray(tmp) || !tmp.length) { throw new TypeError('Chat.init: participants must be ' + 'a non-empty array. Found: ' + tmp); } // Build maps. this.recipientsIds = new Array(tmp.length); this.recipientsIdsQuitted = []; this.recipientToSenderMap = {}; this.recipientToNameMap = {}; this.senderToNameMap = {}; this.senderToRecipientMap = {}; for (i = 0; i < tmp.length; i++) { // Everything i the same if string. if ('string' === typeof tmp[i]) { this.recipientsIds[i] = tmp[i]; this.recipientToNameMap[tmp[i]] = tmp[i]; this.recipientToSenderMap[tmp[i]] = tmp[i]; this.senderToRecipientMap[tmp[i]] = tmp[i]; this.senderToNameMap[tmp[i]] = tmp[i]; } // Sender may be different from receiver if object. else if ('object' === typeof tmp[i]) { rec = tmp[i].recipient; sender = tmp[i].sender; this.recipientsIds[i] = rec; this.recipientToSenderMap[rec] = sender || rec; this.recipientToNameMap[rec] = tmp[i].name || rec; this.senderToRecipientMap[sender] = rec; this.senderToNameMap[sender] = this.recipientToNameMap[rec]; } else { throw new TypeError('Chat.init: participants array must ' + 'contain string or object. Found: ' + tmp[i]); } } // Other. this.uncollapseOnMsg = opts.uncollapseOnMsg || false; this.printStartTime = opts.printStartTime || false; this.printNames = opts.printNames || false; if (opts.initialMsg) { if ('object' !== typeof opts.initialMsg) { throw new TypeError('Chat.init: initialMsg must be ' + 'object or undefined. Found: ' + opts.initialMsg); } this.initialMsg = opts.initialMsg; } this.on('uncollapsed', function() { // Make sure that we do not have the title highlighted any more. that.setTitle(that.title); if (that.recipientsIds.length) { node.say(that.chatEvent + '_COLLAPSE', that.recipientsIds, false); } }); this.on('collapsed', function() { if (that.recipientsIds.length) { node.say(that.chatEvent + '_COLLAPSE', that.recipientsIds, true); } }); this.on('destroyed', function() { if (that.recipientsIds.length) { node.say(that.chatEvent + '_QUIT', that.recipientsIds); } }); this.showIsTyping = 'undefined' === typeof opts.showIsTyping ? true : !!opts.showIsTyping; }; Chat.prototype.append = function() { var that, inputGroup, initialText; that = this; this.chatDiv = W.get('div', { className: 'chat_chat' }); this.bodyDiv.appendChild(this.chatDiv); if (!this.receiverOnly) { // Input group. inputGroup = document.createElement('div'); inputGroup.className = 'chat_inputgroup'; this.textarea = W.get('textarea', { className: 'chat_textarea form-control', placeholder: this.getText('textareaPlaceholder') }); inputGroup.appendChild(this.textarea); if (this.useSubmitButton) { this.submitButton = W.get('button', { className: 'btn-sm btn-info form-control chat_submit', innerHTML: this.getText('submitButton') }); this.submitButton.onclick = function() { that.sendMsg(); if ('function' === typeof that.textarea.focus) { that.textarea.focus(); } }; inputGroup.appendChild(this.submitButton); } if (this.useSubmitEnter || this.showIsTyping) { this.textarea.onkeydown = function(e) { if (that.useSubmitEnter) { e = e || window.event; if ((e.keyCode || e.which) === 13) that.sendMsg(); else sendAmTyping(that); } else if (that.showIsTyping) { sendAmTyping(that); } }; } this.bodyDiv.appendChild(inputGroup); } if (this.printStartTime) { W.add('div', this.chatDiv, { innerHTML: Date(J.getDate()), className: 'chat_event' }); initialText = true; } if (this.printNames) { W.add('div', this.chatDiv, { className: 'chat_event', innerHTML: 'Participants: ' + J.keys(this.senderToNameMap).join(', ') }); initialText = true; } if (initialText) { W.add('div', this.chatDiv, { className: 'chat_event', innerHTML: '&nbsp;' }); } if (this.initialMsg) { this.writeMsg(this.initialMsg.id ? 'incoming' : 'outgoing', this.initialMsg); } }; /** * ### Chat.readTextarea * * Reads the value of the textarea, trims it, and removes it from textarea * * @return {string} The current value in the textarea */ Chat.prototype.readTextarea = function() { var txt; txt = this.textarea.value; this.textarea.value = ''; return txt.trim(); }; /** * ### Chat.writeMsg * * Writes (and formats) a message (or an event) in the message area * * Chat is scrolled up so that the message is last always on focus. * * @param {string} code A value indicating the the type of msg. Available: * 'incoming', 'outgoing', and anything else. * @param {object} data The content of the message and the id of the sender * * @return {HTMLElement} c The div just inserted with the msg * * @see Chat.chatDiv */ Chat.prototype.writeMsg = function(code, data) { var c; c = (code === 'incoming' || code === 'outgoing') ? code : 'event'; c = W.add('div', this.chatDiv, { innerHTML: this.getText(code, data), className: 'chat_msg chat_msg_' + c }); this.scrollToBottom(); return c; }; /** * ### Chat.writeMsg * * It calls preprocess and renders a msg from data * * If msg is a function it executes it to render it. * * @param {object} data The content of the message * @param {string} code A value indicating the the type of msg. Available: * 'incoming', 'outgoing', and anything else. * * @return {string} msg The rendered msg * * @see Chat.chatDiv */ Chat.prototype.renderMsg = function(data, code) { var msg; if ('function' === typeof this.preprocessMsg) { this.preprocessMsg(data, code); } if ('function' === typeof data.msg) { msg = data.msg(data, code); } else { msg = data.msg; } return msg; }; /** * ### Chat.scrollToBottom * * Scrolls the chat to the last message */ Chat.prototype.scrollToBottom = function() { this.chatDiv.scrollTop = this.chatDiv.scrollHeight; }; Chat.prototype.listeners = function() { var that = this; node.on.data(this.chatEvent, function(msg) { if (!that.handleMsg(msg)) return; that.stats.received++; // Store message if so requested. if (that.storeMsgs) { that.db.insert({ from: msg.from, text: msg.data, time: node.timer.getTimeSince('step'), timestamp: J.now() }); } // Remove is typing sign, if any. that.clearIsTyping(msg.from); msg = { msg: that.renderMsg(msg.data, 'incoming'), id: msg.from }; that.writeMsg('incoming', msg); }); node.on.data(this.chatEvent + '_QUIT', function(msg) { var i, len, rec; if (!that.handleMsg(msg)) return; that.writeMsg('quit', { id: msg.from }); len = that.recipientsIds.length; for ( i = 0 ; i < len ; i++) { if (that.recipientsIds[i] === that.senderToRecipientMap[msg.from]) { rec = that.recipientsIds.splice(i, 1); that.recipientsIdsQuitted.push(rec); if (that.recipientsIds.length === 0) { that.writeMsg('noMoreParticipants'); that.disable(); } break; } } node.warn('Chat: participant quitted not found: ' + msg.from); }); node.on.data(this.chatEvent + '_COLLAPSE', function(msg) { if (!that.handleMsg(msg)) return; that.writeMsg('collapse', { id: msg.from, collapsed: msg.data}); }); node.on.data(this.chatEvent + '_TYPING', function(msg) { if (!that.handleMsg(msg)) return; that.addIsTyping(msg.from); }); }; Chat.prototype.addIsTyping = function(id) { var t, div, that; // Stop existing timeouts. t = this.isTypingTimeouts[id]; if (t) clearTimeout(t); // Make or show the div. div = this.isTypingDivs[id]; if (div) { // Move last and show.. this.chatDiv.appendChild(div); div.style.display = ''; } else { this.isTypingDivs[id] = this.writeMsg('incoming', { msg: this.getText('isTyping'), id: id }); } this.scrollToBottom(); // Add new timeout (msg are sent every 4000). that = this; this.isTypingTimeouts[id] = setTimeout(function() { that.clearIsTyping(id); that.isTypingTimeouts[id] = null; }, 3000); }; Chat.prototype.clearIsTyping = function(id) { if (this.isTypingTimeouts[id]) { clearTimeout(this.isTypingTimeouts[id]); this.isTypingTimeouts[id] = null; } // Keep the div element, just hide it, it will be recycled. if (this.isTypingDivs[id]) { this.isTypingDivs[id].style.display = 'none'; } }; /** * ### Chat.handleMsg * * Checks a (incoming) message and takes some actions * * If chat is minimized, it maximizes it if option `uncollapseOnMsg` * it TRUE; otherwise, it increments the stats for unread messages. * * @param {string} msg The content of the message * * @return {boolean} TRUE if the message is valid * * @see Chat.chatDiv */ Chat.prototype.handleMsg = function(msg) { var from; from = msg.from; if (from === node.player.id || from === node.player.sid) { node.warn('Chat: your own message came back: ' + msg.id); return false; } if (this.isCollapsed()) { if (this.uncollapseOnMsg) { this.uncollapse(); this.stats.unread = 0; } else { this.setTitle('<strong>' + this.title + '</strong>'); this.stats.unread++; } } return true; }; Chat.prototype.disable = function() { if (this.submitButton) this.submitButton.disabled = true; this.textarea.disabled = true; this.disabled = true; }; Chat.prototype.enable = function() { if (this.submitButton) this.submitButton.disabled = false; this.textarea.disabled = false; this.disabled = false; }; Chat.prototype.getValues = function() { var out; out = { participants: this.participants, totSent: this.stats.sent, totReceived: this.stats.received, totUnread: this.stats.unread, initialMsg: this.initialMsg }; if (this.db) out.msgs = this.db.fetch(); return out; }; /* ### Chat.sendMsg * * Delivers a msg to the server * * If no options are specified, it reads the textarea. * * @param {object} opts Optional. Configutation options: * - msg: the msg to send. If undefined, it reads the value from textarea; * if function it executes it and uses the return value. * - recipients: array of recipients. Default: this.recipientsIds. * - silent: does not write the msg on the chat. */ Chat.prototype.sendMsg = function(opts) { var to, ids, that; // No msg sent. if (this.isDisabled()) { node.warn('Chat is disable, msg not sent.'); return; } if ('object' === typeof opts) { if ('undefined' !== typeof opts.msg) { if ('object' === typeof opts.msg) { throw new TypeError('Chat.sendMsg: opts.msg cannot be ' + 'object. Found: ' + opts.msg); } } } else { if ('undefined' === typeof opts) { opts = { msg: this.readTextarea() }; } else if ('string' === typeof opts || 'number' === typeof opts) { opts = { msg: opts }; } else { throw new TypeError('Chat.sendMsg: opts must be string, ' + 'number, object, or undefined. Found: ' + opts); } } // Calls preprocessMsg and if opts.msg is function, executes it. opts.msg = this.renderMsg(opts, 'outgoing'); // Move cursor at the beginning. if (opts.msg === '') { node.warn('Chat: message has no text, not sent.'); return; } // Simplify things, if there is only one recipient. ids = opts.recipients || this.recipientsIds; if (ids.length === 0) { node.warn('Chat: empty recipient list, message not sent.'); return; } // Make it a number if array of size 1, so it is faster. to = ids.length === 1 ? ids[0] : ids; node.say(this.chatEvent, to, opts); if (!opts.silent) { that = this; // TODO: check the comment: // to not used now. this.writeMsg('outgoing', opts); // Make sure the cursor goes back to top. if (that.textarea) { setTimeout(function() { that.textarea.value = ''; }); } } // Clear any typing timeout. if (this.amTypingTimeout) { clearTimeout(this.amTypingTimeout); this.amTypingTimeout = null; } } // ## Helper functions. // ### sendMsg // Reads the textarea and delivers the msg to the server. function sendAmTyping(that) { var to; if (that.isDisabled()) return; // Do not send too many notifications. if (that.amTypingTimeout) return; // Simplify things, if there is only one recipient. to = that.recipientsIds; if (!to.length) return; else if (to.length === 1) to = to[0]; // No new notifications for 4s. that.amTypingTimeout = setTimeout(function() { that.amTypingTimeout = null; }, 4000); node.say(that.chatEvent + '_TYPING', to); } })(node);