From 2b36693240f48967b24b4896988ca1528f51ecdd Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 12 Nov 2018 22:03:28 -0700 Subject: [PATCH 1/9] WIP: User Interrupt Queue * All queueing of messages/etc. * Queueing across nodes * Start on interruption points for displaying queued items * Start on a multi-node messaging system using such a queue --- art/themes/luciano_blocktronics/theme.hjson | 15 +++ core/ansi_term.js | 2 +- core/client.js | 16 +-- core/client_connections.js | 19 +-- core/menu_module.js | 83 ++++++++++--- core/node_msg.js | 126 ++++++++++++++++++++ core/user_interrupt_queue.js | 58 +++++++++ 7 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 core/node_msg.js create mode 100644 core/user_interrupt_queue.js diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index aa720934..f359539f 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -353,6 +353,21 @@ } } + nodeMessage: { + 0: { + mci: { + SM1: { + width: 22 + itemFormat: "|00|07{text} |08(|07{userName}|08)" + focusItemFormat: "|00|15{text} |07(|15{userName}|07)" + } + ET2: { + width: 70 + } + } + } + } + messageAreaViewPost: { 0: { diff --git a/core/ansi_term.js b/core/ansi_term.js index 44c82464..f00fd011 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -56,7 +56,7 @@ exports.getSyncTERMFontFromAlias = getSyncTERMFontFromAlias; exports.setSyncTermFontWithAlias = setSyncTermFontWithAlias; exports.setCursorStyle = setCursorStyle; exports.setEmulatedBaudRate = setEmulatedBaudRate; -exports.vtxHyperlink = vtxHyperlink; +exports.vtxHyperlink = vtxHyperlink; // // See also diff --git a/core/client.js b/core/client.js index 1caaa85d..6f4aec79 100644 --- a/core/client.js +++ b/core/client.js @@ -32,13 +32,14 @@ ----/snip/---------------------- */ // ENiGMA½ -const term = require('./client_term.js'); -const ansi = require('./ansi_term.js'); -const User = require('./user.js'); -const Config = require('./config.js').get; -const MenuStack = require('./menu_stack.js'); -const ACS = require('./acs.js'); -const Events = require('./events.js'); +const term = require('./client_term.js'); +const ansi = require('./ansi_term.js'); +const User = require('./user.js'); +const Config = require('./config.js').get; +const MenuStack = require('./menu_stack.js'); +const ACS = require('./acs.js'); +const Events = require('./events.js'); +const UserInterruptQueue = require('./user_interrupt_queue.js'); // deps const stream = require('stream'); @@ -84,6 +85,7 @@ function Client(/*input, output*/) { this.menuStack = new MenuStack(this); this.acs = new ACS(this); this.mciCache = {}; + this.interruptQueue = new UserInterruptQueue(this); this.clearMciCache = function() { this.mciCache = {}; diff --git a/core/client_connections.js b/core/client_connections.js index 93bb9465..f0bca4d7 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -15,11 +15,16 @@ exports.getActiveNodeList = getActiveNodeList; exports.addNewClient = addNewClient; exports.removeClient = removeClient; exports.getConnectionByUserId = getConnectionByUserId; +exports.getConnectionByNodeId = getConnectionByNodeId; const clientConnections = []; -exports.clientConnections = clientConnections; +exports.clientConnections = clientConnections; -function getActiveConnections() { return clientConnections; } +function getActiveConnections(authUsersOnly = false) { + return clientConnections.filter(conn => { + return ((authUsersOnly && conn.user.isAuthenticated()) || !authUsersOnly); + }); +} function getActiveNodeList(authUsersOnly) { @@ -29,11 +34,7 @@ function getActiveNodeList(authUsersOnly) { const now = moment(); - const activeConnections = getActiveConnections().filter(ac => { - return ((authUsersOnly && ac.user.isAuthenticated()) || !authUsersOnly); - }); - - return _.map(activeConnections, ac => { + return _.map(getActiveConnections(authUsersOnly), ac => { const entry = { node : ac.node, authenticated : ac.user.isAuthenticated(), @@ -118,3 +119,7 @@ function removeClient(client) { function getConnectionByUserId(userId) { return getActiveConnections().find( ac => userId === ac.user.userId ); } + +function getConnectionByNodeId(nodeId) { + return getActiveConnections().find( ac => nodeId == ac.node ); +} diff --git a/core/menu_module.js b/core/menu_module.js index ee1502cb..ce8eb556 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -25,15 +25,13 @@ exports.MenuModule = class MenuModule extends PluginModule { this.menuName = options.menuName; this.menuConfig = options.menuConfig; this.client = options.client; - //this.menuConfig.options = options.menuConfig.options || {}; this.menuMethods = {}; // methods called from @method's this.menuConfig.config = this.menuConfig.config || {}; - this.cls = _.get(this.menuConfig.config, 'cls', Config().menus.cls); - - //this.cls = _.isBoolean(this.menuConfig.options.cls) ? this.menuConfig.options.cls : Config().menus.cls; - this.viewControllers = {}; + + // *initial* interruptable state for this menu + this.disableInterruption(); } enter() { @@ -44,6 +42,14 @@ exports.MenuModule = class MenuModule extends PluginModule { this.detachViewControllers(); } + toggleInterruptionAndDisplayQueued(cb) { + this.enableInterruption(); + this.displayQueuedInterruptions( () => { + this.disableInterruption(); + return cb(null); + }); + } + initSequence() { const self = this; const mciData = {}; @@ -51,8 +57,11 @@ exports.MenuModule = class MenuModule extends PluginModule { async.series( [ + function beforeArtInterrupt(callback) { + return self.toggleInterruptionAndDisplayQueued(callback); + }, function beforeDisplayArt(callback) { - self.beforeArt(callback); + return self.beforeArt(callback); }, function displayMenuArt(callback) { if(!_.isString(self.menuConfig.art)) { @@ -160,6 +169,48 @@ exports.MenuModule = class MenuModule extends PluginModule { // nothing in base } + neverInterruptable() { + return this.menuConfig.config.interruptable === 'never'; + } + + enableInterruption() { + if(!this.neverInterruptable()) { + this.interruptable = true; + } + } + + disableInterruption() { + if(!this.neverInterruptable()) { + this.interruptable = false; + } + } + + displayQueuedInterruptions(cb) { + if(true !== this.interruptable) { + return cb(null); + } + + async.whilst( + () => this.client.interruptQueue.hasItems(), + next => { + this.client.interruptQueue.display( (err, interruptItem) => { + if(err) { + return next(err); + } + + if(interruptItem.pause) { + return this.pausePrompt(next); + } + + return next(null); + }); + }, + err => { + return cb(err); + } + ) + } + getSaveState() { // nothing in base } @@ -178,11 +229,15 @@ exports.MenuModule = class MenuModule extends PluginModule { return this.prevMenu(cb); // no next, go to prev } - return this.client.menuStack.next(cb); + this.displayQueuedInterruptions( () => { + return this.client.menuStack.next(cb); + }); } prevMenu(cb) { - return this.client.menuStack.prev(cb); + this.displayQueuedInterruptions( () => { + return this.client.menuStack.prev(cb); + }); } gotoMenu(name, options, cb) { @@ -234,13 +289,13 @@ exports.MenuModule = class MenuModule extends PluginModule { } autoNextMenu(cb) { - const self = this; - - function gotoNextMenu() { - if(self.haveNext()) { - return menuUtil.handleNext(self.client, self.menuConfig.next, {}, cb); + const gotoNextMenu = () => { + if(this.haveNext()) { + this.displayQueuedInterruptions( () => { + return menuUtil.handleNext(this.client, this.menuConfig.next, {}, cb); + }); } else { - return self.prevMenu(cb); + return this.prevMenu(cb); } } diff --git a/core/node_msg.js b/core/node_msg.js new file mode 100644 index 00000000..2c6f23ed --- /dev/null +++ b/core/node_msg.js @@ -0,0 +1,126 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { Errors } = require('./enig_error.js'); +const { + getActiveNodeList, + getConnectionByNodeId, +} = require('./client_connections.js'); +const UserInterruptQueue = require('./user_interrupt_queue.js'); + +// deps +const series = require('async/series'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Node Message', + desc : 'Multi-node messaging', + author : 'NuSkooler', +}; + +const FormIds = { + sendMessage : 0, +}; + +const MciViewIds = { + sendMessage : { + nodeSelect : 1, + message : 2, + preview : 3, + + customRangeStart : 10, + } +} + +exports.getModule = class NodeMessageModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + + this.menuMethods = { + sendMessage : (formData, extraArgs, cb) => { + const nodeId = formData.value.node; + const message = formData.value.message; + + const interruptItem = { + contents : message, + } + + if(0 === nodeId) { + // ALL nodes + UserInterruptQueue.queueGlobalOtherActive(interruptItem, this.client); + } else { + UserInterruptQueue.queueGlobal(interruptItem, [ getConnectionByNodeId(nodeId) ]); + } + + return this.prevMenu(cb); + }, + } + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + series( + [ + (next) => { + return this.prepViewController('sendMessage', FormIds.sendMessage, mciData.menu, next); + }, + (next) => { + const nodeSelectView = this.viewControllers.sendMessage.getView(MciViewIds.sendMessage.nodeSelect); + if(!nodeSelectView) { + return next(Errors.MissingMci(`Missing node selection MCI ${MciViewIds.sendMessage.nodeSelect}`)); + } + + this.prepareNodeList(); + + nodeSelectView.on('index update', idx => { + this.nodeListSelectionIndexUpdate(idx); + }); + + nodeSelectView.setItems(this.nodeList); + nodeSelectView.redraw(); + this.nodeListSelectionIndexUpdate(0); + return next(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + prepareNodeList() { + // standard node list with {text} field added for compliance + this.nodeList = [{ + text : '-ALL-', + // dummy fields: + node : 0, + authenticated : false, + userId : 0, + action : 'N/A', + userName : 'Everyone', + realName : 'All Users', + location : 'N/A', + affils : 'N/A', + timeOn : 'N/A', + }].concat(getActiveNodeList(true) + .map(node => Object.assign(node, { text : node.node.toString() } )) + ).filter(node => node.node !== this.client.node); // remove our client's node + this.nodeList.sort( (a, b) => a.node - b.node ); // sort by node + } + + nodeListSelectionIndexUpdate(idx) { + const node = this.nodeList[idx]; + if(!node) { + return; + } + this.updateCustomViewTextsWithFilter('sendMessage', MciViewIds.sendMessage.customRangeStart, node); + } +} \ No newline at end of file diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js new file mode 100644 index 00000000..a29fd4df --- /dev/null +++ b/core/user_interrupt_queue.js @@ -0,0 +1,58 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Art = require('./art.js'); +const { + getActiveConnections +} = require('./client_connections.js'); +const ANSI = require('./ansi_term.js'); + +// deps +const _ = require('lodash'); + +module.exports = class UserInterruptQueue +{ + constructor(client) { + this.client = client; + this.queue = []; + } + + static queueGlobal(interruptItem, connections) { + connections.forEach(conn => { + conn.interruptQueue.queueItem(interruptItem); + }); + } + + // common shortcut: queue global, all active clients minus |client| + static queueGlobalOtherActive(interruptItem, client) { + const otherConnections = getActiveConnections(true).filter(ac => ac.node !== client.node); + return UserInterruptQueue.queueGlobal(interruptItem, otherConnections ); + } + + queueItem(interruptItem) { + interruptItem.pause = _.get(interruptItem, 'pause', true); + this.queue.push(interruptItem); + } + + hasItems() { + return this.queue.length > 0; + } + + display(cb) { + const interruptItem = this.queue.pop(); + if(!interruptItem) { + return cb(null); + } + + if(interruptItem.cls) { + this.client.term.rawWrite(ANSI.clearScreen()); + } else { + this.client.term.rawWrite('\r\n\r\n'); + } + + Art.display(this.client, interruptItem.contents, err => { + return cb(err, interruptItem); + }); + } +}; \ No newline at end of file From d8f07083108935a7ffb47919266d247b2b71df65 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 13 Nov 2018 20:03:54 -0700 Subject: [PATCH 2/9] getActiveNodeList -> getActiveConnectionList (be consistent) --- core/client_connections.js | 4 ++-- core/node_msg.js | 4 ++-- core/whos_online.js | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/core/client_connections.js b/core/client_connections.js index f0bca4d7..6b8faf65 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -11,7 +11,7 @@ const moment = require('moment'); const hashids = require('hashids'); exports.getActiveConnections = getActiveConnections; -exports.getActiveNodeList = getActiveNodeList; +exports.getActiveConnectionList = getActiveConnectionList; exports.addNewClient = addNewClient; exports.removeClient = removeClient; exports.getConnectionByUserId = getConnectionByUserId; @@ -26,7 +26,7 @@ function getActiveConnections(authUsersOnly = false) { }); } -function getActiveNodeList(authUsersOnly) { +function getActiveConnectionList(authUsersOnly) { if(!_.isBoolean(authUsersOnly)) { authUsersOnly = true; diff --git a/core/node_msg.js b/core/node_msg.js index 2c6f23ed..276dae2f 100644 --- a/core/node_msg.js +++ b/core/node_msg.js @@ -5,7 +5,7 @@ const { MenuModule } = require('./menu_module.js'); const { Errors } = require('./enig_error.js'); const { - getActiveNodeList, + getActiveConnectionList, getConnectionByNodeId, } = require('./client_connections.js'); const UserInterruptQueue = require('./user_interrupt_queue.js'); @@ -110,7 +110,7 @@ exports.getModule = class NodeMessageModule extends MenuModule { location : 'N/A', affils : 'N/A', timeOn : 'N/A', - }].concat(getActiveNodeList(true) + }].concat(getActiveConnectionList(true) .map(node => Object.assign(node, { text : node.node.toString() } )) ).filter(node => node.node !== this.client.node); // remove our client's node this.nodeList.sort( (a, b) => a.node - b.node ); // sort by node diff --git a/core/whos_online.js b/core/whos_online.js index 0ea2321a..5910bd29 100644 --- a/core/whos_online.js +++ b/core/whos_online.js @@ -2,9 +2,9 @@ 'use strict'; // ENiGMA½ -const { MenuModule } = require('./menu_module.js'); -const { getActiveNodeList } = require('./client_connections.js'); -const { Errors } = require('./enig_error.js'); +const { MenuModule } = require('./menu_module.js'); +const { getActiveConnectionList } = require('./client_connections.js'); +const { Errors } = require('./enig_error.js'); // deps const async = require('async'); @@ -43,7 +43,7 @@ exports.getModule = class WhosOnlineModule extends MenuModule { return cb(Errors.MissingMci(`Missing online list MCI ${MciViewIds.onlineList}`)); } - const onlineList = getActiveNodeList(true).slice(0, onlineListView.height).map( + const onlineList = getActiveConnectionList(true).slice(0, onlineListView.height).map( oe => Object.assign(oe, { text : oe.userName, timeOn : _.upperFirst(oe.timeOn.humanize()) }) ); From 330e1efa781544d96bec3552f9b478d25747f51c Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 13 Nov 2018 21:11:33 -0700 Subject: [PATCH 3/9] Updates to user interruptions & node-to-node message module * Can now have header/footer art on node-to-node messages * 'text' and more advanced 'contents' fields * format via 'messageFormat' --- core/node_msg.js | 86 ++++++++++++++++++++++++++++++------ core/user_interrupt_queue.js | 15 +++++-- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/core/node_msg.js b/core/node_msg.js index 276dae2f..fde5f35e 100644 --- a/core/node_msg.js +++ b/core/node_msg.js @@ -9,10 +9,14 @@ const { getConnectionByNodeId, } = require('./client_connections.js'); const UserInterruptQueue = require('./user_interrupt_queue.js'); +const { getThemeArt } = require('./theme.js'); +const { pipeToAnsi } = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); // deps -const series = require('async/series'); -const _ = require('lodash'); +const series = require('async/series'); +const _ = require('lodash'); +const async = require('async'); exports.moduleInfo = { name : 'Node Message', @@ -44,18 +48,16 @@ exports.getModule = class NodeMessageModule extends MenuModule { const nodeId = formData.value.node; const message = formData.value.message; - const interruptItem = { - contents : message, - } + this.createInterruptItem(message, (err, interruptItem) => { + if(0 === nodeId) { + // ALL nodes + UserInterruptQueue.queueGlobalOtherActive(interruptItem, this.client); + } else { + UserInterruptQueue.queueGlobal(interruptItem, [ getConnectionByNodeId(nodeId) ]); + } - if(0 === nodeId) { - // ALL nodes - UserInterruptQueue.queueGlobalOtherActive(interruptItem, this.client); - } else { - UserInterruptQueue.queueGlobal(interruptItem, [ getConnectionByNodeId(nodeId) ]); - } - - return this.prevMenu(cb); + return this.prevMenu(cb); + }); }, } } @@ -96,6 +98,64 @@ exports.getModule = class NodeMessageModule extends MenuModule { }); } + createInterruptItem(message, cb) { + const textFormatObj = { + fromUserName : this.client.user.username, + fromRealName : this.client.user.properties.real_name, + fromNodeId : this.client.node, + message : message, + }; + + const messageFormat = + this.config.messageFormat || + 'Message from {fromUserName} on node {fromNodeId}:\r\n{message}'; + + const item = { + text : stringFormat(messageFormat, textFormatObj), + pause : true, + }; + + const getArt = (name, callback) => { + const spec = _.get(this.config, `art.${name}`); + if(!spec) { + return callback(null); + } + const getArtOpts = { + name : spec, + client : this.client, + random : false, + }; + getThemeArt(getArtOpts, (err, artInfo) => { + // ignore errors + return callback(artInfo ? artInfo.data : null); + }); + }; + + async.waterfall( + [ + (callback) => { + getArt('header', headerArt => { + return callback(null, headerArt); + }); + }, + (headerArt, callback) => { + getArt('footer', footerArt => { + return callback(null, headerArt, footerArt); + }); + }, + (headerArt, footerArt, callback) => { + if(headerArt || footerArt) { + item.contents = `${headerArt || ''}\r\n${pipeToAnsi(item.text)}\r\n${footerArt || ''}`; + } + return callback(null); + } + ], + err => { + return cb(err, item); + } + ); + } + prepareNodeList() { // standard node list with {text} field added for compliance this.nodeList = [{ diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js index a29fd4df..be6767d5 100644 --- a/core/user_interrupt_queue.js +++ b/core/user_interrupt_queue.js @@ -7,6 +7,7 @@ const { getActiveConnections } = require('./client_connections.js'); const ANSI = require('./ansi_term.js'); +const { pipeToAnsi } = require('./color_codes.js'); // deps const _ = require('lodash'); @@ -31,6 +32,9 @@ module.exports = class UserInterruptQueue } queueItem(interruptItem) { + if(!_.isString(interruptItem.contents) && !_.isString(interruptItem.text)) { + return; + } interruptItem.pause = _.get(interruptItem, 'pause', true); this.queue.push(interruptItem); } @@ -51,8 +55,13 @@ module.exports = class UserInterruptQueue this.client.term.rawWrite('\r\n\r\n'); } - Art.display(this.client, interruptItem.contents, err => { - return cb(err, interruptItem); - }); + if(interruptItem.contents) { + Art.display(this.client, interruptItem.contents, err => { + this.client.term.rawWrite('\r\n\r\n'); + return cb(err, interruptItem); + }); + } else { + return this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), cb); + } } }; \ No newline at end of file From 23af00e7ec0145c0a89c1afae8916adea81cacd2 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 14 Nov 2018 19:52:55 -0700 Subject: [PATCH 4/9] Add timestamp --- core/node_msg.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/node_msg.js b/core/node_msg.js index fde5f35e..f98db5f1 100644 --- a/core/node_msg.js +++ b/core/node_msg.js @@ -17,6 +17,7 @@ const stringFormat = require('./string_format.js'); const series = require('async/series'); const _ = require('lodash'); const async = require('async'); +const moment = require('moment'); exports.moduleInfo = { name : 'Node Message', @@ -104,6 +105,7 @@ exports.getModule = class NodeMessageModule extends MenuModule { fromRealName : this.client.user.properties.real_name, fromNodeId : this.client.node, message : message, + timestamp : moment(), }; const messageFormat = From b3930d1999d04808ca9743b2109f0c86458fc7f1 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 14 Nov 2018 20:51:11 -0700 Subject: [PATCH 5/9] WIP on node-to-node msg + Preview * Fix node IDs + Add new MenuModule method for validating MCI codes --- core/menu_module.js | 16 ++++++++++++++ core/node_msg.js | 49 ++++++++++++++++++++++++++++++----------- core/view_controller.js | 4 ++++ 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/core/menu_module.js b/core/menu_module.js index ce8eb556..2dd40b9e 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -549,4 +549,20 @@ exports.MenuModule = class MenuModule extends PluginModule { }); } } + + validateMCIByViewIds(formName, viewIds, cb) { + if(!Array.isArray(viewIds)) { + viewIds = [ viewIds ]; + } + const form = _.get(this, [ 'viewControllers', formName ] ); + if(!form) { + return cb(Errors.DoesNotExist(`Form does not exist: ${formName}`)); + } + for(let i = 0; i < viewIds.length; ++i) { + if(!form.hasView(viewIds[i])) { + return cb(Errors.MissingMci(`Missing MCI ${viewIds[i]}`)); + } + } + return cb(null); + } }; diff --git a/core/node_msg.js b/core/node_msg.js index f98db5f1..412e14ab 100644 --- a/core/node_msg.js +++ b/core/node_msg.js @@ -46,15 +46,18 @@ exports.getModule = class NodeMessageModule extends MenuModule { this.menuMethods = { sendMessage : (formData, extraArgs, cb) => { - const nodeId = formData.value.node; + const nodeId = this.nodeList[formData.value.node].node; // index from from -> node! const message = formData.value.message; this.createInterruptItem(message, (err, interruptItem) => { - if(0 === nodeId) { + if(-1 === nodeId) { // ALL nodes UserInterruptQueue.queueGlobalOtherActive(interruptItem, this.client); } else { - UserInterruptQueue.queueGlobal(interruptItem, [ getConnectionByNodeId(nodeId) ]); + const conn = getConnectionByNodeId(nodeId); + if(conn) { + UserInterruptQueue.queueGlobal(interruptItem, [ conn ]); + } } return this.prevMenu(cb); @@ -71,15 +74,18 @@ exports.getModule = class NodeMessageModule extends MenuModule { series( [ - (next) => { - return this.prepViewController('sendMessage', FormIds.sendMessage, mciData.menu, next); + (callback) => { + return this.prepViewController('sendMessage', FormIds.sendMessage, mciData.menu, callback); }, - (next) => { + (callback) => { + return this.validateMCIByViewIds( + 'sendMessage', + [ MciViewIds.sendMessage.nodeSelect, MciViewIds.sendMessage.message ], + callback + ); + }, + (callback) => { const nodeSelectView = this.viewControllers.sendMessage.getView(MciViewIds.sendMessage.nodeSelect); - if(!nodeSelectView) { - return next(Errors.MissingMci(`Missing node selection MCI ${MciViewIds.sendMessage.nodeSelect}`)); - } - this.prepareNodeList(); nodeSelectView.on('index update', idx => { @@ -89,7 +95,24 @@ exports.getModule = class NodeMessageModule extends MenuModule { nodeSelectView.setItems(this.nodeList); nodeSelectView.redraw(); this.nodeListSelectionIndexUpdate(0); - return next(null); + return callback(null); + }, + (callback) => { + const previewView = this.viewControllers.sendMessage.getView(MciViewIds.sendMessage.preview); + if(!previewView) { + return callback(null); // preview is optional + } + + const messageView = this.viewControllers.sendMessage.getView(MciViewIds.sendMessage.message); + let timerId; + messageView.on('key press', () => { + clearTimeout(timerId); + const focused = this.viewControllers.sendMessage.getFocusedView(); + if(focused === messageView) { + previewView.setText(messageView.getData()); + focused.setFocus(true); + } + }, 500); } ], err => { @@ -163,7 +186,7 @@ exports.getModule = class NodeMessageModule extends MenuModule { this.nodeList = [{ text : '-ALL-', // dummy fields: - node : 0, + node : -1, authenticated : false, userId : 0, action : 'N/A', @@ -173,7 +196,7 @@ exports.getModule = class NodeMessageModule extends MenuModule { affils : 'N/A', timeOn : 'N/A', }].concat(getActiveConnectionList(true) - .map(node => Object.assign(node, { text : node.node.toString() } )) + .map(node => Object.assign(node, { text : -1 == node.node ? '-ALL-' : node.node.toString() } )) ).filter(node => node.node !== this.client.node); // remove our client's node this.nodeList.sort( (a, b) => a.node - b.node ); // sort by node } diff --git a/core/view_controller.js b/core/view_controller.js index cd0dd3b0..de6f1f05 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -431,6 +431,10 @@ ViewController.prototype.getView = function(id) { return this.views[id]; }; +ViewController.prototype.hasView = function(id) { + return this.getView(id) ? true : false; +} + ViewController.prototype.getViewsByMciCode = function(mciCode) { if(!Array.isArray(mciCode)) { mciCode = [ mciCode ]; From cc9c1439273346f09909ed15f2219e9f8e76fa1d Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 14 Nov 2018 22:04:29 -0700 Subject: [PATCH 6/9] Cleanup & prep for real-time interrupt --- core/menu_module.js | 22 +++++++++++++--------- core/user_interrupt_queue.js | 6 +++++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/core/menu_module.js b/core/menu_module.js index 2dd40b9e..1e68823c 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -42,14 +42,6 @@ exports.MenuModule = class MenuModule extends PluginModule { this.detachViewControllers(); } - toggleInterruptionAndDisplayQueued(cb) { - this.enableInterruption(); - this.displayQueuedInterruptions( () => { - this.disableInterruption(); - return cb(null); - }); - } - initSequence() { const self = this; const mciData = {}; @@ -185,6 +177,14 @@ exports.MenuModule = class MenuModule extends PluginModule { } } + toggleInterruptionAndDisplayQueued(cb) { + this.enableInterruption(); + this.displayQueuedInterruptions( () => { + this.disableInterruption(); + return cb(null); + }); + } + displayQueuedInterruptions(cb) { if(true !== this.interruptable) { return cb(null); @@ -193,7 +193,7 @@ exports.MenuModule = class MenuModule extends PluginModule { async.whilst( () => this.client.interruptQueue.hasItems(), next => { - this.client.interruptQueue.display( (err, interruptItem) => { + this.client.interruptQueue.displayNext( (err, interruptItem) => { if(err) { return next(err); } @@ -211,6 +211,10 @@ exports.MenuModule = class MenuModule extends PluginModule { ) } + attemptInterruptNow(interruptItem, cb) { + return cb(null, false); + } + getSaveState() { // nothing in base } diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js index be6767d5..1a62e063 100644 --- a/core/user_interrupt_queue.js +++ b/core/user_interrupt_queue.js @@ -43,12 +43,16 @@ module.exports = class UserInterruptQueue return this.queue.length > 0; } - display(cb) { + displayNext(cb) { const interruptItem = this.queue.pop(); if(!interruptItem) { return cb(null); } + return interruptItem ? this.displayWithItem(interruptItem, cb) : cb(null); + } + + displayWithItem(interruptItem, cb) { if(interruptItem.cls) { this.client.term.rawWrite(ANSI.clearScreen()); } else { From 080d1727c2b12ea218ef75f96d9307b993f551d2 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 14 Nov 2018 22:47:20 -0700 Subject: [PATCH 7/9] WIP on real-time interruptions (ie: incoming message) Still need work on *when* they are allowed with good defaults, etc. --- core/menu_module.js | 32 ++++++++++++++++++-------------- core/user_interrupt_queue.js | 20 +++++++++++++++++--- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/core/menu_module.js b/core/menu_module.js index 1e68823c..bd75c94c 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -192,19 +192,7 @@ exports.MenuModule = class MenuModule extends PluginModule { async.whilst( () => this.client.interruptQueue.hasItems(), - next => { - this.client.interruptQueue.displayNext( (err, interruptItem) => { - if(err) { - return next(err); - } - - if(interruptItem.pause) { - return this.pausePrompt(next); - } - - return next(null); - }); - }, + next => this.client.interruptQueue.displayNext(next), err => { return cb(err); } @@ -212,7 +200,21 @@ exports.MenuModule = class MenuModule extends PluginModule { } attemptInterruptNow(interruptItem, cb) { - return cb(null, false); + if(true !== this.interruptable) { + return cb(null, false); // don't eat up the item; queue for later + } + + // + // Default impl: clear screen -> standard display -> reload menu + // + this.client.interruptQueue.displayWithItem(Object.assign({}, interruptItem, { cls : true }), err => { + if(err) { + return cb(err, false); + } + this.reload(err => { + return cb(err, err ? false : true); + }); + }); } getSaveState() { @@ -311,6 +313,8 @@ exports.MenuModule = class MenuModule extends PluginModule { } else { return gotoNextMenu(); } + } else { + this.enableInterruption(); } } diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js index 1a62e063..e48fc2ea 100644 --- a/core/user_interrupt_queue.js +++ b/core/user_interrupt_queue.js @@ -35,8 +35,17 @@ module.exports = class UserInterruptQueue if(!_.isString(interruptItem.contents) && !_.isString(interruptItem.text)) { return; } + + // pause defaulted on interruptItem.pause = _.get(interruptItem, 'pause', true); - this.queue.push(interruptItem); + + this.client.currentMenuModule.attemptInterruptNow(interruptItem, (err, ateIt) => { + if(err) { + // :TODO: Log me + } else if(true !== ateIt) { + this.queue.push(interruptItem); + } + }); } hasItems() { @@ -61,8 +70,13 @@ module.exports = class UserInterruptQueue if(interruptItem.contents) { Art.display(this.client, interruptItem.contents, err => { - this.client.term.rawWrite('\r\n\r\n'); - return cb(err, interruptItem); + if(err) { + return cb(err); + } + //this.client.term.rawWrite('\r\n\r\n'); // :TODO: Prob optional based on contents vs text + this.client.currentMenuModule.pausePrompt( () => { + return cb(null); + }); }); } else { return this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), cb); From 2fb3ce83a34019ee01ca7eaa88f01598d4eef665 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 24 Nov 2018 09:33:20 -0700 Subject: [PATCH 8/9] Spelling... --- core/menu_module.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/core/menu_module.js b/core/menu_module.js index 2ad30b8a..87f96dc0 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -30,7 +30,7 @@ exports.MenuModule = class MenuModule extends PluginModule { this.cls = _.get(this.menuConfig.config, 'cls', Config().menus.cls); this.viewControllers = {}; - // *initial* interruptable state for this menu + // *initial* Interruptible state for this menu this.disableInterruption(); } @@ -167,18 +167,18 @@ exports.MenuModule = class MenuModule extends PluginModule { } neverInterruptable() { - return this.menuConfig.config.interruptable === 'never'; + return this.menuConfig.config.Interruptible === 'never'; } enableInterruption() { if(!this.neverInterruptable()) { - this.interruptable = true; + this.Interruptible = true; } } disableInterruption() { if(!this.neverInterruptable()) { - this.interruptable = false; + this.Interruptible = false; } } @@ -191,7 +191,7 @@ exports.MenuModule = class MenuModule extends PluginModule { } displayQueuedInterruptions(cb) { - if(true !== this.interruptable) { + if(true !== this.Interruptible) { return cb(null); } @@ -205,7 +205,7 @@ exports.MenuModule = class MenuModule extends PluginModule { } attemptInterruptNow(interruptItem, cb) { - if(true !== this.interruptable) { + if(true !== this.Interruptible) { return cb(null, false); // don't eat up the item; queue for later } From fe44f2c4d6bbfa619210bf6cd371ba90d6e5019e Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 30 Nov 2018 23:20:44 -0700 Subject: [PATCH 9/9] User interrupts & node module ready to rock. ...maybe with bugs? --- art/themes/luciano_blocktronics/MMENU.ANS | Bin 3527 -> 3574 bytes art/themes/luciano_blocktronics/theme.hjson | 14 ++-- core/menu_module.js | 71 +++++++++----------- core/node_msg.js | 32 +++++---- core/user_interrupt_queue.js | 33 ++++++--- docs/_includes/nav.md | 1 + docs/misc/user-interrupt.md | 17 +++++ docs/modding/node-msg.md | 41 +++++++++++ misc/menu_template.in.hjson | 57 ++++++++++++++++ 9 files changed, 198 insertions(+), 68 deletions(-) create mode 100644 docs/misc/user-interrupt.md create mode 100644 docs/modding/node-msg.md diff --git a/art/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS index 75251877665dfef7961dfa9376f8ed1b294279ad..ad029e33f765113432e247acfd1b6a04a6e67e4d 100644 GIT binary patch delta 118 zcmX>u{Y`p97$>WAw2^`H8` { - this.disableInterruption(); - return cb(null); - }); - } - displayQueuedInterruptions(cb) { - if(true !== this.Interruptible) { + if(MenuModule.InterruptTypes.Never === this.interrupt) { return cb(null); } + let opts = { cls : true }; // clear screen for first message + async.whilst( () => this.client.interruptQueue.hasItems(), - next => this.client.interruptQueue.displayNext(next), + next => { + this.client.interruptQueue.displayNext(opts, err => { + opts = {}; + return next(err); + }); + }, err => { return cb(err); } - ) + ); } attemptInterruptNow(interruptItem, cb) { - if(true !== this.Interruptible) { + if(MenuModule.InterruptTypes.Realtime !== this.interrupt) { return cb(null, false); // don't eat up the item; queue for later } // // Default impl: clear screen -> standard display -> reload menu // - this.client.interruptQueue.displayWithItem(Object.assign({}, interruptItem, { cls : true }), err => { - if(err) { - return cb(err, false); - } - this.reload(err => { - return cb(err, err ? false : true); + this.client.interruptQueue.displayWithItem( + Object.assign({}, interruptItem, { cls : true }), + err => { + if(err) { + return cb(err, false); + } + this.reload(err => { + return cb(err, err ? false : true); + }); }); - }); } getSaveState() { @@ -308,7 +299,7 @@ exports.MenuModule = class MenuModule extends PluginModule { } else { return this.prevMenu(cb); } - } + }; if(_.has(this.menuConfig, 'runtime.autoNext') && true === this.menuConfig.runtime.autoNext) { if(this.hasNextTimeout()) { @@ -318,8 +309,6 @@ exports.MenuModule = class MenuModule extends PluginModule { } else { return gotoNextMenu(); } - } else { - this.enableInterruption(); } } diff --git a/core/node_msg.js b/core/node_msg.js index 412e14ab..72c54430 100644 --- a/core/node_msg.js +++ b/core/node_msg.js @@ -2,16 +2,16 @@ 'use strict'; // ENiGMA½ -const { MenuModule } = require('./menu_module.js'); -const { Errors } = require('./enig_error.js'); +const { MenuModule } = require('./menu_module.js'); const { getActiveConnectionList, getConnectionByNodeId, -} = require('./client_connections.js'); -const UserInterruptQueue = require('./user_interrupt_queue.js'); -const { getThemeArt } = require('./theme.js'); -const { pipeToAnsi } = require('./color_codes.js'); -const stringFormat = require('./string_format.js'); +} = require('./client_connections.js'); +const UserInterruptQueue = require('./user_interrupt_queue.js'); +const { getThemeArt } = require('./theme.js'); +const { pipeToAnsi } = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); +const { renderStringLength } = require('./string_util.js'); // deps const series = require('async/series'); @@ -47,23 +47,27 @@ exports.getModule = class NodeMessageModule extends MenuModule { this.menuMethods = { sendMessage : (formData, extraArgs, cb) => { const nodeId = this.nodeList[formData.value.node].node; // index from from -> node! - const message = formData.value.message; + const message = _.get(formData.value, 'message', '').trim(); + + if(0 === renderStringLength(message)) { + return this.prevMenu(cb); + } this.createInterruptItem(message, (err, interruptItem) => { if(-1 === nodeId) { // ALL nodes - UserInterruptQueue.queueGlobalOtherActive(interruptItem, this.client); + UserInterruptQueue.queue(interruptItem, { omit : this.client }); } else { const conn = getConnectionByNodeId(nodeId); if(conn) { - UserInterruptQueue.queueGlobal(interruptItem, [ conn ]); + UserInterruptQueue.queue(interruptItem, { clients : conn } ); } } return this.prevMenu(cb); }); }, - } + }; } mciReady(mciData, cb) { @@ -123,12 +127,14 @@ exports.getModule = class NodeMessageModule extends MenuModule { } createInterruptItem(message, cb) { + const dateTimeFormat = this.config.dateTimeFormat || this.client.currentTheme.helpers.getDateTimeFormat(); + const textFormatObj = { fromUserName : this.client.user.username, fromRealName : this.client.user.properties.real_name, fromNodeId : this.client.node, message : message, - timestamp : moment(), + timestamp : moment().format(dateTimeFormat), }; const messageFormat = @@ -208,4 +214,4 @@ exports.getModule = class NodeMessageModule extends MenuModule { } this.updateCustomViewTextsWithFilter('sendMessage', MciViewIds.sendMessage.customRangeStart, node); } -} \ No newline at end of file +}; diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js index e48fc2ea..2e72bbd1 100644 --- a/core/user_interrupt_queue.js +++ b/core/user_interrupt_queue.js @@ -19,18 +19,26 @@ module.exports = class UserInterruptQueue this.queue = []; } - static queueGlobal(interruptItem, connections) { - connections.forEach(conn => { - conn.interruptQueue.queueItem(interruptItem); + static queue(interruptItem, opts) { + opts = opts || {}; + if(!opts.clients) { + let omitNodes = []; + if(Array.isArray(opts.omit)) { + omitNodes = opts.omit; + } else if(opts.omit) { + omitNodes = [ opts.omit ]; + } + omitNodes = omitNodes.map(n => _.isNumber(n) ? n : n.node); + opts.clients = getActiveConnections(true).filter(ac => !omitNodes.includes(ac.node)); + } + if(!Array.isArray(opts.clients)) { + opts.clients = [ opts.clients ]; + } + opts.clients.forEach(c => { + c.interruptQueue.queueItem(interruptItem); }); } - // common shortcut: queue global, all active clients minus |client| - static queueGlobalOtherActive(interruptItem, client) { - const otherConnections = getActiveConnections(true).filter(ac => ac.node !== client.node); - return UserInterruptQueue.queueGlobal(interruptItem, otherConnections ); - } - queueItem(interruptItem) { if(!_.isString(interruptItem.contents) && !_.isString(interruptItem.text)) { return; @@ -52,12 +60,17 @@ module.exports = class UserInterruptQueue return this.queue.length > 0; } - displayNext(cb) { + displayNext(options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } const interruptItem = this.queue.pop(); if(!interruptItem) { return cb(null); } + Object.assign(interruptItem, options); return interruptItem ? this.displayWithItem(interruptItem, cb) : cb(null); } diff --git a/docs/_includes/nav.md b/docs/_includes/nav.md index 84785dbe..b6303960 100644 --- a/docs/_includes/nav.md +++ b/docs/_includes/nav.md @@ -79,6 +79,7 @@ - [Download Manager]({{ site.baseurl }}{% link modding/file-base-download-manager.md %}) - [Web Download Manager]({{ site.baseurl }}{% link modding/file-base-web-download-manager.md %}) - [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %}) + - [Node to Node Messaging]({{ site.baseurl }}{% link modding/node-msg.md %}) - Administration - [oputil]({{ site.baseurl }}{% link admin/oputil.md %}) diff --git a/docs/misc/user-interrupt.md b/docs/misc/user-interrupt.md new file mode 100644 index 00000000..fe20fdd9 --- /dev/null +++ b/docs/misc/user-interrupt.md @@ -0,0 +1,17 @@ +--- +layout: page +title: User Interruptions +--- +## User Interruptions +ENiGMA½ provides functionality to "interrupt" a user for various purposes such as a [node-to-node message](/docs/modding/node-msg.md). User interruptions can be queued and displayed at the next opportune time such as when switching to a new menu, or realtime if appropriate. + +## Standard Menu Behavior +Standard menus control interruption by the `interrupt` config block option, which may be set to one of the following values: +* `never`: Never interrupt the user when on this menu. +* `queued`: Queue interrupts for the next opportune time. Any queued message(s) will then be shown. This is the default. +* `realtime`: If possible, display messages in realtime. That is, show them right away. Standard menus that do not override default behavior will show the message then reload. + + +## See Also +See [user_interrupt_queue.js](/core/user_interrupt_queue.js) as well as usage within [menu_module.js](/core/menu_module.js). + diff --git a/docs/modding/node-msg.md b/docs/modding/node-msg.md new file mode 100644 index 00000000..5377e68a --- /dev/null +++ b/docs/modding/node-msg.md @@ -0,0 +1,41 @@ +--- +layout: page +title: Node to Node Messaging +--- +## The Node to Node Messaging Module +The node to node messaging (`node_msg`) module allows users to send messages to one or more users on different nodes. Messages delivered to nodes follow standard [User Interruption](/docs/misc/user-interrupt.md) rules. + +## Configuration +### Config Block +Available `config` block entries: +* `dateTimeFormat`: [moment.js](https://momentjs.com) style format. Defaults to current theme → system `short` format. +* `messageFormat`: Format string for sent messages. Defaults to `Message from {fromUserName} on node {fromNodeId}:\r\n{message}`. The following format object members are available: + * `fromUserName`: Username who sent the message. + * `fromRealName`: Real name of user who sent the message. + * `fromNodeId`: Node ID where the message was sent from. + * `message`: User entered message. May contain pipe color codes. + * `timestamp`: A timestamp formatted using `dateTimeFormat` above. +* `art`: Block containing: + * `header`: Art spec for header to display with message. + * `footer`: Art spec for footer to display with message. + +## Theming +### MCI Codes +1. Node selection. Must be a View that allows lists such as `SpinnerMenuView` (`%SM1`), `HorizontalMenuView` (`%HM1`), etc. +2. Message entry (`%ET2`). +3. Message preview (`%TL3`). A rendered (that is, pipe codes resolved) preview of the text in `%ET2`. + +10+: Custom using `itemFormat`. See below. + +### Item Format +The following `itemFormat` object is provided for MCI 1 and 10+ for the currently selected item/node: +* `text`: Node ID or "-ALL-" (All nodes). +* `node`: Node ID or `-1` in the case of all nodes. +* `userId`: User ID. +* `action`: User's action. +* `userName`: Username. +* `realName`: Real name. +* `location`: User's location. +* `affils`: Affiliations. +* `timeOn`: How long the user has been online (approx). + diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson index 6e1d889d..27ad3003 100644 --- a/misc/menu_template.in.hjson +++ b/misc/menu_template.in.hjson @@ -991,8 +991,13 @@ prompt: menuCommand config: { font: cp437 + interrupt: realtime } submit: [ + { + value: { command: "MSG" } + action: @menu:nodeMessage + } { value: { command: "G" } action: @menu:fullLogoffSequence @@ -1064,6 +1069,46 @@ ] } + nodeMessage: { + desc: Node Messaging + module: node_msg + art: NODEMSG + config: { + cls: true + art: { + header: NODEMSGHDR + footer: NODEMSGFTR + } + } + form: { + 0: { + mci: { + SM1: { + argName: node + } + ET2: { + argName: message + submit: true + } + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + submit: { + *: [ + { + value: { message: null } + action: @method:sendMessage + } + ] + } + } + } + } + mainMenuLastCallers: { desc: Last Callers module: last_callers @@ -1609,6 +1654,9 @@ desc: Doors Menu art: DOORMNU prompt: menuCommand + config: { + interrupt: realtime + } submit: [ { value: { command: "G" } @@ -1738,6 +1786,9 @@ art: MSGMNU desc: Message Area prompt: messageMenuCommand + config: { + interrupt: realtime + } submit: [ { value: { command: "P" } @@ -2464,6 +2515,9 @@ art: MAILMNU desc: Mail Menu prompt: menuCommand + config: { + interrupt: realtime + } submit: [ { value: { command: "C" } @@ -2666,6 +2720,9 @@ desc: File Base art: FMENU prompt: fileMenuCommand + config: { + interrupt: realtime + } submit: [ { value: { menuOption: "L" }