UNPKG

cabal-cli

Version:
664 lines (608 loc) 25.6 kB
var chalk = require('chalk') var Commander = require('./commands.js') var neatLog = require('neat-log') var strftime = require('strftime') var views = require('./views') var util = require('./util') var fs = require('fs') var path = require('path') var welcomePath = path.join(__dirname, 'welcome.txt') var welcomeMessage = fs.readFileSync(welcomePath).toString().split('\n') function NeatScreen (props) { if (!(this instanceof NeatScreen)) return new NeatScreen(props) this.client = props.client this.config = props.frontendConfig this.commander = Commander(this, this.client) this.lastInputTime = 0 this.inputTimer = null this.BACKLOG_BATCH = 250 this.additionalBacklog = 0 var self = this this.neat = neatLog(this.renderApp.bind(this), { fullscreen: true, style: function (start, cursor, end) { if (!cursor) cursor = ' ' return start + chalk.underline(cursor) + end } } ) this.neat.input.on('update', () => { // debounce keyboard input events so pasting from clipboard is fast var now = Date.now() var ms = 20 if (this.inputTimer) { } else if (now > this.lastInputTime + ms) { this.lastInputTime = now this.neat.render() } else { this.inputTimer = setTimeout(() => { this.inputTimer = null this.neat.render() }, ms) } }) this.neat.input.on('enter', (line) => this.commander.process(line)) // welcome to autocomplete town this.neat.input.on('tab', () => { var line = this.neat.input.rawLine() if (line.length > 1 && line[0] === '/') { const parts = line.split(/\s+/g) // command completion if (parts.length === 1) { var soFar = line.slice(1) var commands = Object.keys(this.client.getCommands()) var matchingCommands = commands.filter(cmd => cmd.startsWith(soFar)) if (matchingCommands.length === 1) { this.neat.input.set('/' + matchingCommands[0]) } // argument completion } else if (parts.length === 2) { const command = parts[0].slice(1) // we only have channel completion atm: return if command is unrelated to channels if (!['leave', 'l', 'join', 'j'].includes(command)) { return } // channel completion let channelFragment = parts[1].trim() if (this.state.prevChannelFragment && channelFragment.startsWith(this.state.prevChannelFragment)) { channelFragment = this.state.prevChannelFragment } else { // clear up old state delete this.state.prevChannelFragment delete this.state.prevChannelId } const channels = this.state.cabal.getChannels() const matches = channels.filter(ch => ch.startsWith(channelFragment)) if (matches.length === 0) { return } const chid = this.state.prevChannelId !== undefined ? (this.state.prevChannelId + 1) % matches.length : 0 const channelMatch = matches[chid] this.neat.input.set(`${parts[0]} ${channelMatch}`) this.state.prevChannelId = chid this.state.prevChannelFragment = channelFragment } } else { const cabalUsers = this.client.getUsers() // nick completion const users = Object.keys(cabalUsers) .map(key => cabalUsers[key]) .sort(util.cmpUser) .map(user => user.name || user.key.substring(0, 8)) let match = line.trim().split(/\s+/g).slice(-1)[0] // usual case is we want to autocomplete the last word on a line const cursor = this.neat.input.cursor let lindex = -1 let rindex = -1 // cursorWandering === true => we're trying to autocomplete something in the middle of the line; i.e the cursor has wandered away from the end const cursorWandering = cursor !== line.length if (cursorWandering) { // find left-most boundary of potential nickname fragment to autocomplete for (let i = cursor - 1; i >= 0; i--) { if (line.charAt(i) === ' ' || i === 0) { lindex = i break } } // find right-most boundary of nickname for (let i = cursor; i <= line.length; i++) { if (line.charAt(i) === ' ') { rindex = i break } } match = line.slice(lindex, rindex).trim() } if (!match) { return } // determine if we are tabbing through alternatives of similar-starting nicks let cyclingNicks = false if (this.state.prevCompletion !== undefined && match.toLowerCase().startsWith(this.state.prevCompletion.toLowerCase())) { // use the original word we typed before tab-completing it match = this.state.prevCompletion cyclingNicks = true } else { delete this.state.prevCompletion delete this.state.prevNickIndex } // proceed to figure out the closest match const filteredUsers = Array.from(new Set(users.filter(user => user.search(/\s+/) === -1 && user.toLowerCase().startsWith(match.toLowerCase())))) // filter out duplicate nicks and people with spaces in their nicks, fuck that if (filteredUsers.length > 0) { const userIndex = cyclingNicks ? (this.state.prevNickIndex + 1) % filteredUsers.length : 0 const filteredUser = filteredUsers[userIndex] const currentInput = this.neat.input.rawLine() let completedInput = currentInput.slice(0, currentInput.length - match.length) + filteredUser // i.e. repeated tabbing of similar-starting nicks if (cyclingNicks) { let prevNick = filteredUsers[this.state.prevNickIndex] // we autocompleted a single nick w/ colon+space added, adjust for colon+space if (currentInput.length === prevNick.length + 2) { prevNick += ': ' } completedInput = currentInput.slice(0, currentInput.length - prevNick.length) + filteredUser } // i.e. cursor has been moved from end of line if (cursorWandering) { completedInput = (lindex > 0) ? currentInput.slice(0, lindex + 1) : '' completedInput += filteredUser + currentInput.slice(rindex) } // ux: we only autcompleted a single nick, add a colon and space if (completedInput === filteredUser) { completedInput += ': ' } this.neat.input.set(completedInput) // update the input line with our newly tab-completed nick // when neat-input.set() is used the cursor is automatically moved to the end of the line, // if the cursor is wandering we instead want the cursor to be just after the autocompleted name if (cursorWandering) { this.neat.input.cursor = cursor + (filteredUser.length - currentInput.slice(lindex, rindex).trim().length) } this.state.prevCompletion = match this.state.prevNickIndex = userIndex } } }) this.neat.input.on('up', () => { var i = Math.min(this.commander.history.length - 1, this.commander.historyIndex + 1) var j = this.commander.history.length - 1 - i if (j >= 0 && j < this.commander.history.length) { this.commander.historyIndex = i var command = this.commander.history[j] this.neat.input.set(command) } }) this.neat.input.on('down', () => { var len = this.commander.history.length var i = Math.max(-1, this.commander.historyIndex - 1) this.commander.historyIndex = i if (i < 0) { var line = this.neat.input.rawLine() if (line.length > 0 && line !== this.commander.history[len - 1]) { this.commander.history.push(line) } this.neat.input.set('') } else { var command = this.commander.history[len - 1 - i] this.neat.input.set(command) } }) // set channel with alt-# this.neat.input.on('alt-1', () => { setChannelByIndex(0) }) this.neat.input.on('alt-2', () => { setChannelByIndex(1) }) this.neat.input.on('alt-3', () => { setChannelByIndex(2) }) this.neat.input.on('alt-4', () => { setChannelByIndex(3) }) this.neat.input.on('alt-5', () => { setChannelByIndex(4) }) this.neat.input.on('alt-6', () => { setChannelByIndex(5) }) this.neat.input.on('alt-7', () => { setChannelByIndex(6) }) this.neat.input.on('alt-8', () => { setChannelByIndex(7) }) this.neat.input.on('alt-9', () => { setChannelByIndex(8) }) this.neat.input.on('alt-0', () => { setChannelByIndex(9) }) this.neat.input.on('alt-l', () => { this.commander.process('/ids') }) this.neat.input.on('keypress', (ch, key) => { if (!key || !key.name) return if (key.name === 'home') this.neat.input.cursor = 0 else if (key.name === 'end') this.neat.input.cursor = this.neat.input.rawLine().length // clear state for nick autocompletion if something other than tab has been pressed else if (key.name !== 'tab' && this.state.prevCompletion) { delete this.state.prevCompletion delete this.state.prevNickIndex } else { return } this.bus.emit('render') }) // move between window panes with ctrl+j this.neat.input.on('alt-n', () => { var i = this.state.windowPanes.indexOf(this.state.selectedWindowPane) if (i !== -1) { i = ++i % this.state.windowPanes.length this.state.selectedWindowPane = this.state.windowPanes[i] this.bus.emit('render') } }) // move up/down pane with ctrl+{n,p} this.neat.input.on('ctrl-p', () => { cycleCurrentPane.bind(this)(-1) }) this.neat.input.on('ctrl-n', () => { cycleCurrentPane.bind(this)(1) }) // redraw the screen this.neat.input.on('ctrl-l', () => { this.neat.clear() }) // cycle to next unread channel this.neat.input.on('ctrl-r', () => { // prioritize channels with mentions. after all those are exhausted, continue to unread channels const channels = Array.from(new Set(Object.keys(this.state.mentions).concat(Object.keys(this.state.unreadChannels)))) channels.sort() if (channels.length === 0) return this.loadChannel(channels[0]) }) function cycleCurrentPane (dir) { var i if (this.state.selectedWindowPane === 'cabals') { i = this.state.cabals.findIndex((key) => key === this.state.cabal.key) i += dir * 1 i = i % this.state.cabals.length if (i < 0) i += this.state.cabals.length setCabalByIndex.bind(this)(i) } else { var channels = this.state.cabal.getChannels({ includePM: true, onlyJoined: true }) i = channels.indexOf(this.state.cabal.getCurrentChannel()) i += dir * 1 i = i % channels.length if (i < 0) i += channels.length setChannelByIndex.bind(this)(i) } } function setCabalByIndex (n) { if (n < 0 || n >= this.state.cabals.length) return this.showCabal(this.state.cabals[n]) } function setChannelByIndex (n) { var channels = self.state.cabal.getChannels({ includePM: true, onlyJoined: true }) if (n < 0 || n >= channels.length) return self.loadChannel(channels[n]) } const scrollOffset = 11 this.neat.input.on('pageup', () => { this.state.messageScrollback += process.stdout.rows - scrollOffset }) this.neat.input.on('pagedown', () => { this.state.messageScrollback = Math.max(0, this.state.messageScrollback - (process.stdout.rows - scrollOffset)) }) this.neat.input.on('shift-pageup', () => { this.state.userScrollback = Math.max(0, this.state.userScrollback - (process.stdout.rows - 9)) }) this.neat.input.on('shift-pagedown', () => { this.state.userScrollback += process.stdout.rows - 9 }) this.neat.use((state, bus) => { state.neat = this.neat this.bus = bus /* all state variables used in neat screen */ state.messages = [] state.topic = '' state.unreadChannels = {} state.mentions = {} state.selectedWindowPane = 'channels' state.windowPanes = [state.selectedWindowPane] state.config = this.config state.messageTimeLength = strftime(this.config.messageTimeformat, new Date()).length state.collision = {} this.state = state Object.defineProperty(this.state, 'cabal', { get: () => { return this.client.cabalToDetails() } }) Object.defineProperty(this.state, 'cabals', { get: () => { return this.client.getCabalKeys() } }) this.initializeCabalClient() }) } NeatScreen.prototype._handleUpdate = function (updatedDetails) { if (updatedDetails && updatedDetails.key !== this.client.getCurrentCabal().key) { // an unfocused cabal sent an update, don't render its changes return } this.state.cabal = updatedDetails var channels = this.client.getJoinedChannels() this.state.windowPanes = this.state.cabals.length > 1 ? ['channels', 'cabals'] : ['channels'] this._updateCollisions() // reset cause we fill them up below this.state.unreadChannels = {} this.state.mentions = {} channels.forEach((ch) => { var unreads = this.client.getNumberUnreadMessages(ch) if (unreads > 0) { this.state.unreadChannels[ch] = unreads } var mentions = this.client.getMentions(ch) if (mentions.length > 0) { this.state.mentions[ch] = mentions } }) this.state.topic = this.state.cabal.getTopic() var opts = {} if (!this.messageScrollback > 0) { // only update view with messages if we're at the bottom i.e. not paging up this.processMessages(opts) } this.bus.emit('render') this.updateTimer = null } NeatScreen.prototype.initializeCabalClient = function () { var details = this.client.getCurrentCabal() this.state.cabal = details this.state.messageScrollback = 0 this.state.userScrollback = 0 this.client.getCabalKeys().forEach((key) => { welcomeMessage.map((m) => this.client.getDetails(key).addStatusMessage({ text: m }, '!status')) this.state.moderationKeys = this.state.cabal.core.adminKeys.map((k) => { return { key: k, type: 'admin' } }).concat(this.state.cabal.core.modKeys.map((k) => { return { key: k, type: 'mod' } })) if (this.state.moderationKeys.length > 0) { const moderationMessage = [ 'you joined via a moderation key, meaning you are allowing someone else to help administer moderation on your behalf.'] // comment out how to remove applied moderators until it actually has a lasting effect across sessions, see https://github.com/cabal-club/cabal-cli/pull/190#discussion_r430021350 // moderationMessage.push('if you would like to remove the applied moderation keys, type:') // this.state.moderationKeys.forEach((k) => { // moderationMessage.push(`/un${k.type} ${k.key}`) // }) moderationMessage.push('for more information, type /moderation') moderationMessage.forEach((text) => { this.client.getDetails(key).addStatusMessage({ text }, '!status') }) } }) this.bus.emit('render') this.registerUpdateHandler(details) this.loadChannel('!status') } // check for collisions in the first four hex chars of the users in the cabal. used in NeatScreen.prototype.formatMessage NeatScreen.prototype._updateCollisions = function () { this.state.collision = {} const userKeys = Object.keys(this.state.cabal.getUsers()) userKeys.forEach((u) => { const collision = typeof this.state.collision[u.slice(0, 4)] !== 'undefined' // if there is a collision in the first 4 chars of a pub key in the cabal, // expand it to the largest length that lets us disambiguate between the colliding ids this.state.collision[u.slice(0, 4)] = { collision, idlen: (collision ? util.unambiguous(userKeys, u) : 4) } }) } NeatScreen.prototype.registerUpdateHandler = function (cabal) { if (!this._updateHandler) this._updateHandler = {} if (this._updateHandler[cabal.key]) return // we already have a handler for that cabal this._updateHandler[cabal.key] = (updatedDetails) => { // insert timeout handler for to debounce events when tons are streaming in if (this.updateTimer) clearTimeout(this.updateTimer) this.updateTimer = setTimeout(() => { // update view this._handleUpdate(updatedDetails) }, 20) } // register an event handler for all updates from the cabal cabal.on('update', this._updateHandler[cabal.key]) // create & register event handlers for channel archiving events const processChannelArchiving = (type, { channel, reason, key, isLocal }) => { const issuer = this.client.getUsers()[key] if (!issuer || isLocal) { return } reason = reason ? `(${chalk.cyan('reason:')} ${reason})` : '' const issuerName = issuer && issuer.name ? issuer.name : key.slice(0, 8) const action = type === 'archive' ? 'archived' : 'unarchived' const text = `${issuerName} ${chalk.magenta(action)} channel ${chalk.cyan(channel)} ${reason}` this.client.addStatusMessage({ text }) this.bus.emit('render') } cabal.on('channel-archive', (envelope) => { processChannelArchiving('archive', envelope) }) cabal.on('channel-unarchive', (envelope) => { processChannelArchiving('unarchive', envelope) }) cabal.on('private-message', (envelope) => { // never display PMs inline from a hidden user if (envelope.author.isHidden()) return // don't display the notif if we're just sending something to ourselves (covered by publish-private-message event) if (envelope.author.key === cabal.getLocalUser().key) return // don't display the notification if we're already looking at the pm it came from if (cabal.getCurrentChannel() === envelope.channel) { return } const text = `PM [${envelope.author.name}]: ${envelope.message.value.content.text}` this.client.addStatusMessage({ text: chalk.magentaBright(text) }) }) cabal.on('publish-private-message', message => { // don't display the notification if we're already looking at the pm it came from if (cabal.getCurrentChannel() === message.content.channel) { return } const users = cabal.getUsers() const pubkey = message.content.channel let name = pubkey.slice(0, 8) if (pubkey in users) { // never display PMs inline from a hidden user if (users[pubkey].isHidden()) return name = users[pubkey].name } const text = `PM to [${name}]: ${message.content.text}` this.client.addStatusMessage({ text: chalk.magentaBright(text) }) }) } NeatScreen.prototype._pagesize = function () { return views.getPageSize() } NeatScreen.prototype.processMessages = function (opts, cb) { opts = opts || {} if (!cb) cb = function () {} opts.newerThan = opts.newerThan || null opts.olderThan = opts.olderThan || Date.now() opts.amount = opts.amount || this._pagesize() * 2.5 opts.amount += this.additionalBacklog // var unreadCount = this.client.getNumberUnreadMessages() this.client.getMessages(opts, (msgs) => { this.state.messages = [] msgs.forEach((msg, i) => { const user = this.client.getUsers()[msg.key] if (user && user.isHidden(opts.channel)) return this.state.messages.push(this.formatMessage(msg)) }) this.bus.emit('render') cb.bind(this)() }) } NeatScreen.prototype.showCabal = function (cabal) { this.state.cabal = this.client.focusCabal(cabal) this.registerUpdateHandler(this.state.cabal) this.commander.setActiveCabal(this.state.cabal) this.client.focusChannel() this.bus.emit('render') } NeatScreen.prototype.renderApp = function (state) { if (process.stdout.columns > 80) return views.big(state) else return views.small(state) } // use to write anything else to the screen, e.g. info messages or emotes NeatScreen.prototype.writeLine = function (text) { this.client.addStatusMessage({ text }) this.bus.emit('render') } NeatScreen.prototype.clear = function () { this.state.messages = [] this.bus.emit('render') } NeatScreen.prototype.setPane = function (pane) { this.state.selectedWindowPane = pane this.bus.emit('render') } NeatScreen.prototype.moreBacklog = function () { this.additionalBacklog += this.BACKLOG_BATCH const text = `adding ${this.BACKLOG_BATCH} messages to backlog, total extra messages: ${this.additionalBacklog}` this.client.addStatusMessage({ text }) this.processMessages() } NeatScreen.prototype.loadChannel = function (channel) { this.client.focusChannel(channel) // clear the old channel state this.state.messages = [] this.state.topic = '' this.additionalBacklog = 0 this.processMessages() // load the topic this.state.topic = this.state.cabal.getTopic() } NeatScreen.prototype.render = function () { this.bus.emit('render') } NeatScreen.prototype.formatMessage = function (msg) { var highlight = false /* legend for `msg` below msg = { key: '' value: { timestamp: '' type: '' content: { text: '' } } } */ if (!msg.value.type) { msg.value.type = 'chat/text' } // virtual message type, handled by cabal-client if (msg.value.type === 'status/date-changed') { return { formatted: `${chalk.dim('day changed to ' + strftime('%e %b %Y', new Date(msg.value.timestamp)))}`, raw: msg } } if (msg.value.content && msg.value.timestamp) { const users = this.client.getUsers() const authorSource = users[msg.key] || msg let author = util.sanitizeString(authorSource.name || authorSource.key.slice(0, 8)) // add author field for later use in calculating the left-padding of multi-line messages msg.author = author var localNick = 'uninitialized' if (this.state) { localNick = this.state.cabal.getLocalName() } /* sanitize user inputs to prevent interface from breaking */ localNick = util.sanitizeString(localNick) var msgtxt = msg.value.content.text if (msg.value.type !== 'status') { msgtxt = util.sanitizeString(msgtxt) } var content = msgtxt if (localNick.length > 0 && msgtxt.indexOf(localNick) > -1 && author !== localNick) { highlight = true } if (authorSource.constructor.name === 'User') { if (authorSource.isAdmin()) author = chalk.green('@') + author else if (authorSource.isModerator()) author = chalk.green('%') + author } var color = keyToColour(msg.key) || colours[5] var timestamp = `${chalk.dim(formatTime(msg.value.timestamp, this.config.messageTimeformat))}` let authorText if (msg.value.type === 'status' || msg.value.type === 'chat/moderation') { highlight = false // never highlight from status authorText = `${chalk.dim('-')}${chalk.cyan('status')}${chalk.dim('-')}` } else { /* a user wrote a message, not the !status virtual message */ // if there is a collision in the first 4 characters of a pub key in the cabal, expand it to the largest length that // lets us disambiguate between the two ids in the collision const collision = authorSource.key && this.state.collision[authorSource.key.slice(0, 4)] const pubid = collision && authorSource.key && authorSource.key.slice(0, collision.idlen) if (pubid && this.state.cabal.showIds) { authorText = `${chalk.dim('<')}${highlight ? chalk.whiteBright(author) : chalk[color](author)}${chalk.dim('.')}${chalk.inverse(chalk.cyan(pubid))}${chalk.dim('>')}` } else { authorText = `${chalk.dim('<')}${highlight ? chalk.whiteBright(author) : chalk[color](author)}${chalk.dim('>')}` } var emote = (msg.value.type === 'chat/emote') if (pubid && emote) { authorText = `${chalk.white(author)}${this.state.cabal.showIds ? chalk.dim('.') + chalk.inverse(chalk.cyan(pubid)) : ''}` content = `${chalk.dim(msgtxt)}` } } if (msg.value.type === 'chat/topic') { content = `${chalk.dim(`* sets the topic to ${chalk.cyan(msgtxt)}`)}` } else if (msg.value.type === 'chat/moderation') { const { role, type, issuerid, receiverid } = msg.value.content const issuer = this.client.getUsers()[issuerid] const receiver = this.client.getUsers()[receiverid] let action const reason = msg.value.content.reason ? `(${chalk.cyan('reason:')} ${msg.value.content.reason})` : '' const issuerName = issuer && issuer.name ? issuer.name : issuerid.slice(0, 8) const receiverName = receiver && receiver.name ? receiver.name : receiverid.slice(0, 8) if (['admin', 'mod'].includes(role)) { action = (type === 'add' ? chalk.green('added') : chalk.red('removed')) content = `${issuerName} ${action} ${receiverName} as ${chalk.cyan(role)} ${reason}` } if (role === 'hide') { action = (type === 'add' ? chalk.red('hid') : chalk.green('unhid')) content = `${issuerName} ${action} ${receiverName} ${reason}` } } emote = (emote ? ' * ' : ' ') authorText = (highlight ? chalk.bgRed(chalk.black(authorText)) : authorText) const formattedPrefix = timestamp + emote + authorText + ' ' return { timestamp, emote, author: authorText, content, formattedPrefix, formatted: formattedPrefix + content, raw: msg } } return { formatted: chalk.cyan('unknown message type: ') + chalk.inverse(JSON.stringify(msg.value)), raw: msg } } function formatTime (t, fmt) { return strftime(fmt, new Date(t)) } function keyToColour (key) { var n = 0 for (var i = 0; i < key.length; i++) { n += parseInt(key[i], 16) n = n % colours.length } return colours[n] } var colours = [ 'red', 'green', 'yellow', // 'blue', 'magenta', 'cyan', // 'white', // 'gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright', 'magentaBright', 'cyanBright' // 'whiteBright' ] module.exports = NeatScreen