diff --git a/art/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS index 75251877..ad029e33 100644 Binary files a/art/themes/luciano_blocktronics/MMENU.ANS and b/art/themes/luciano_blocktronics/MMENU.ANS differ diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index d336c2a4..b1fe8aec 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -364,15 +364,21 @@ } nodeMessage: { + config: { + messageFormat: "|00|08 :: |03message from |11{fromUserName} |08/ |03node |11{fromNodeId}|08 @ |11{timestamp} |08::\r\n|07 {message}" + } 0: { mci: { SM1: { - width: 22 - itemFormat: "|00|07{text} |08(|07{userName}|08)" - focusItemFormat: "|00|15{text} |07(|15{userName}|07)" + width: 25 + itemFormat: "|00|03node |07{text} |08(|07{userName}|08)" + focusItemFormat: "|00|11node |15{text} |07(|15{userName}|07)" } ET2: { - width: 70 + width: 65 + } + TL3: { + width: 65 } } } diff --git a/core/menu_module.js b/core/menu_module.js index 87f96dc0..9bac7b03 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -29,9 +29,15 @@ exports.MenuModule = class MenuModule extends PluginModule { this.menuConfig.config = this.menuConfig.config || {}; this.cls = _.get(this.menuConfig.config, 'cls', Config().menus.cls); this.viewControllers = {}; + this.interrupt = (_.get(this.menuConfig.config, 'interrupt', MenuModule.InterruptTypes.Queued)).toLowerCase(); + } - // *initial* Interruptible state for this menu - this.disableInterruption(); + static get InterruptTypes() { + return { + Never : 'never', + Queued : 'queued', + Realtime : 'realtime', + }; } enter() { @@ -55,7 +61,7 @@ exports.MenuModule = class MenuModule extends PluginModule { async.series( [ function beforeArtInterrupt(callback) { - return self.toggleInterruptionAndDisplayQueued(callback); + return self.displayQueuedInterruptions(callback); }, function beforeDisplayArt(callback) { return self.beforeArt(callback); @@ -166,60 +172,45 @@ exports.MenuModule = class MenuModule extends PluginModule { // nothing in base } - neverInterruptable() { - return this.menuConfig.config.Interruptible === 'never'; - } - - enableInterruption() { - if(!this.neverInterruptable()) { - this.Interruptible = true; - } - } - - disableInterruption() { - if(!this.neverInterruptable()) { - this.Interruptible = false; - } - } - - toggleInterruptionAndDisplayQueued(cb) { - this.enableInterruption(); - this.displayQueuedInterruptions( () => { - 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" }