├── .cabal.yml-example ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── cli-2019-04.png ├── cli.js ├── commands.js ├── debug.js ├── neat-screen.js ├── output.js ├── package-lock.json ├── package.json ├── package.sh ├── publish.sh ├── test └── test.js ├── util.js ├── views.js └── welcome.txt /.cabal.yml-example: -------------------------------------------------------------------------------- 1 | cabals: 2 | - cabal://bd45fde0ad866d4069af490f0ca9b07110808307872d4b659a4ff7a4ef85315a 3 | - 22f7763be0e939160dd04137b66aaac8f2179350eec740e57a656dfdf1f4dc29 4 | - cbl://3d45fde0ad866d4069af490f0ca9b07110808307872d4b659a4ff7a4ef853132 5 | 6 | frontend: 7 | # timestamp format, using https://github.com/samsonjs/strftime 8 | # a shorter alternative can be: '%H%M' 9 | messageTimeformat: '%T' 10 | 11 | # indent messages wrapped onto next line to 12 | # after: none, time, nick 13 | messageIndent: nick 14 | 15 | # change preferredPort from 0 if you want to communicate over a custom port 16 | # cabal defaults to port 13331 17 | preferredPort: 0 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: cabal-club 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #~top ignores~ 2 | node_modules/ 3 | *.css 4 | *.vim 5 | *bundle*.js 6 | /html/*.html 7 | *.swo 8 | config.conf 9 | config.js 10 | *.txt 11 | *.pdf 12 | archives 13 | builds 14 | dist 15 | 16 | # Cabal config files 17 | .cabal.yml 18 | 19 | ################# 20 | ## Eclipse 21 | ################# 22 | *.pydevproject 23 | .project 24 | .metadata 25 | bin/ 26 | tmp/ 27 | *.tmp 28 | *.bak 29 | *.swp 30 | *~.nib 31 | local.properties 32 | .classpath 33 | .settings/ 34 | .loadpath 35 | 36 | # External tool builders 37 | .externalToolBuilders/ 38 | 39 | # Locally stored "Eclipse launch configurations" 40 | *.launch 41 | 42 | # CDT-specific 43 | .cproject 44 | 45 | # PDT-specific 46 | .buildpath 47 | 48 | 49 | ################# 50 | ## Visual Studio 51 | ################# 52 | 53 | ## Ignore Visual Studio temporary files, build results, and 54 | ## files generated by popular Visual Studio add-ons. 55 | 56 | # User-specific files 57 | *.suo 58 | *.user 59 | *.sln.docstates 60 | 61 | # Build results 62 | 63 | [Dd]ebug/ 64 | [Rr]elease/ 65 | x64/ 66 | build/ 67 | [Bb]in/ 68 | [Oo]bj/ 69 | 70 | # MSTest test Results 71 | [Tt]est[Rr]esult*/ 72 | [Bb]uild[Ll]og.* 73 | 74 | *_i.c 75 | *_p.c 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.pch 80 | *.pdb 81 | *.pgc 82 | *.pgd 83 | *.rsp 84 | *.sbr 85 | *.tlb 86 | *.tli 87 | *.tlh 88 | *.tmp 89 | *.tmp_proj 90 | *.log 91 | *.vspscc 92 | *.vssscc 93 | .builds 94 | *.pidb 95 | *.log 96 | *.scc 97 | 98 | # Visual C++ cache files 99 | ipch/ 100 | *.aps 101 | *.ncb 102 | *.opensdf 103 | *.sdf 104 | *.cachefile 105 | 106 | # Visual Studio profiler 107 | *.psess 108 | *.vsp 109 | *.vspx 110 | 111 | # Guidance Automation Toolkit 112 | *.gpState 113 | 114 | # ReSharper is a .NET coding add-in 115 | _ReSharper*/ 116 | *.[Rr]e[Ss]harper 117 | 118 | # TeamCity is a build add-in 119 | _TeamCity* 120 | 121 | # DotCover is a Code Coverage Tool 122 | *.dotCover 123 | 124 | # NCrunch 125 | *.ncrunch* 126 | .*crunch*.local.xml 127 | 128 | # Installshield output folder 129 | [Ee]xpress/ 130 | 131 | # DocProject is a documentation generator add-in 132 | DocProject/buildhelp/ 133 | DocProject/Help/*.HxT 134 | DocProject/Help/*.HxC 135 | DocProject/Help/*.hhc 136 | DocProject/Help/*.hhk 137 | DocProject/Help/*.hhp 138 | DocProject/Help/Html2 139 | DocProject/Help/html 140 | 141 | # Click-Once directory 142 | publish/ 143 | 144 | # Publish Web Output 145 | *.Publish.xml 146 | *.pubxml 147 | 148 | # NuGet Packages Directory 149 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 150 | #packages/ 151 | 152 | # Windows Azure Build Output 153 | csx 154 | *.build.csdef 155 | 156 | # Windows Store app package directory 157 | AppPackages/ 158 | 159 | # Others 160 | sql/ 161 | *.Cache 162 | ClientBin/ 163 | [Ss]tyle[Cc]op.* 164 | ~$* 165 | *~ 166 | *.dbmdl 167 | *.[Pp]ublish.xml 168 | *.pfx 169 | *.publishsettings 170 | 171 | # RIA/Silverlight projects 172 | Generated_Code/ 173 | 174 | # Backup & report files from converting an old project file to a newer 175 | # Visual Studio version. Backup files are not needed, because we have git ;-) 176 | _UpgradeReport_Files/ 177 | Backup*/ 178 | UpgradeLog*.XML 179 | UpgradeLog*.htm 180 | 181 | # SQL Server files 182 | App_Data/*.mdf 183 | App_Data/*.ldf 184 | 185 | ############# 186 | ## Windows detritus 187 | ############# 188 | 189 | # Windows image file caches 190 | Thumbs.db 191 | ehthumbs.db 192 | 193 | # Folder config file 194 | Desktop.ini 195 | 196 | # Recycle Bin used on file shares 197 | $RECYCLE.BIN/ 198 | 199 | # Mac crap 200 | .DS_Store 201 | 202 | 203 | ############# 204 | ## Python 205 | ############# 206 | 207 | *.py[co] 208 | 209 | # Packages 210 | *.egg 211 | *.egg-info 212 | dist/ 213 | build/ 214 | eggs/ 215 | parts/ 216 | var/ 217 | sdist/ 218 | develop-eggs/ 219 | .installed.cfg 220 | 221 | # Installer logs 222 | pip-log.txt 223 | 224 | # Unit test / coverage reports 225 | .coverage 226 | .tox 227 | 228 | #Translations 229 | *.mo 230 | 231 | #Mr Developer 232 | .mr.developer.cfg 233 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | join 2 | kappa 3 | todo 4 | todo.json 5 | builds 6 | dist 7 | *.png 8 | package.sh 9 | publish.sh 10 | .travis.yml 11 | test/ 12 | .cabal.yml-example 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | 5 | before_deploy: npm run package 6 | deploy: 7 | provider: releases 8 | api_key: 9 | secure: wOslsvqZcp2q0OPTm1oi83zijA8BLWUnU2480K324PqZdvq6aed7JX/pEAZd4qowXuFiAQHlE2+Awwav0dPFu4dQw0pyaarxKxYs7Js7iP4wbWO0cm9E/jesM1PVV0bQKYZfjJaEZsYYJA6WYoUab0Y6xKIdKuDxSlqmclip0rduTJ/Txe80BucM7EDDDL6QXA8BrEp0AhPuicECny4mWAsiq9rsG9T01fQ7bLtEgU1Yior0n3wXbwZyYaBS77q/e5jiVs3GF670KG9bghQ/HB7+BTlHjY5VuvW84WH+SDYFFaZbGJbhQr5nCJHNltf/6tmRbA6x/9RqrjRREYFoqKFe45pG2Sz6OkXsmKwacg8Llk/KeU+nXZaVFdPlfOpAexyYt/wqX+MM/5V2GLjXR9/mhcp7G/x6OtAvlIGkbqtjWeIuOpVecQHGp9LoH6+PqQbdsvujJ8wqbJ2tO+rTyWbEle2UD25C5erhhBh5Nd75IId26peeGDnORtAI6dC1SMyrVpP2Eksd55n6GiD4JTFdeu5QYUpGt5LZUsrAWT1mNLtmAq9wbpBabeBzBOP2VXfbwuecC4maZb1LaLeuLYW8loHnqUulyrgKF8GqIYkr5AaeAWFPVizIhqRDetpEJb7unJu0eUq3FTmXLoPNnZsKgLxk9LXKovcpvaPrd7g= 10 | skip_cleanup: true 11 | file_glob: true 12 | file: dist/* 13 | on: 14 | repo: cabal-club/cabal-cli 15 | node: '10' 16 | tags: true 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Cabal - p2p chatsoft in the terminal 2 | Copyright (C) 2019 Cabal Developers 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU Affero General Public License as 6 | published by the Free Software Foundation, either version 3 of the 7 | License, or (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cabal-cli 2 | 3 | > Terminal client for cabal, the p2p chat platform. 4 | 5 | See [cabal-core](https://github.com/cabal-club/cabal-core) for the underlying 6 | database & api. 7 | 8 | ![](cli-2019-04.png) 9 | 10 | chat with us: 11 | `npx cabal cabal://cabal.chat` 12 | 13 | ## Installation 14 | 15 | ``` 16 | $ npm install --global cabal 17 | $ cabal --new 18 | ``` 19 | If that fails the newest node is not yet supported by the stack. Try this: 20 | 21 | Install [nvm](https://github.com/nvm-sh/nvm), open a new shell and run 22 | ``` 23 | $ nvm install 12 24 | $ npm install --global cabal 25 | $ cabal --new 26 | ``` 27 | 28 | ## Usage 29 | #### Start a new instance: 30 | ``` 31 | cabal --new 32 | ``` 33 | then copy the key and give it to someone else. 34 | 35 | #### Connect to an existing instance: 36 | ``` 37 | cabal 38 | ``` 39 | e.g. 40 | ``` 41 | cabal cabal://0201400f1aa2e3076a3f17f4521b2cc41e258c446cdaa44742afe6e1b9fd5f82 42 | ``` 43 | 44 | #### Remember cabals for auto-joining 45 | save a cabal to the config 46 | 47 | ``` 48 | cabal --save 49 | ``` 50 | 51 | then connect to all of your saved cabals, by simply running `cabal`: 52 | 53 | ``` 54 | cabal 55 | ``` 56 | 57 | show saved cabals with `--cabals` and remove a saved cabal with `--forget` 58 | 59 | ``` 60 | cabal --cabals 61 | cabal --forget 62 | ``` 63 | 64 | #### Save an alias to a key 65 | 66 | create a local name for a key. 67 | 68 | ``` 69 | cabal --alias --key 70 | cabal 71 | ``` 72 | 73 | #### Scan a QR code to join a cabal: 74 | Cabal can use a webcam connected to your computer to read a cabal key from a QR code. 75 | For this to work, you'll need to install an additional system dependency: 76 | - Linux: `sudo apt-get install fswebcam` 77 | - MacOS: `brew install imagesnap` 78 | ``` 79 | # Hold up your QR code in front of the webcam and then run: 80 | cabal --qr 81 | ``` 82 | 83 | #### Headless mode 84 | 85 | This will run cabal without a UI. You can use this to seed a cabal (e.g. on a VPS) and make its data more available: 86 | ``` 87 | cabal --seed 88 | ``` 89 | 90 | #### Custom port 91 | If you have a tightly configured firewall and need to port-forward a port, the default port Cabal uses is port `13331`. 92 | You can change this with the `--port` flag, or setting `preferredPort` in your .cabal.yml config file. 93 | 94 | ``` 95 | cabal --seed --port 7331 96 | ``` 97 | 98 | ## Commands 99 | ```py 100 | /add, /cabal 101 | add a cabal 102 | /new 103 | create a new cabal 104 | /nick, /n 105 | change your display name 106 | /emote, /me 107 | write an old-school text emote 108 | /names 109 | display the names of the currently online peers 110 | /channels 111 | display the cabal's channels 112 | /panes 113 | set pane to navigate up and down in panes: channels, cabals 114 | /join, /j 115 | join a new channel 116 | /leave, /l 117 | leave a channel 118 | /clear 119 | clear the current backscroll 120 | /help 121 | display this help message 122 | /qr 123 | generate a qr code with the current cabal's address 124 | /quit, /exit 125 | exit the cabal process 126 | /topic, /motd 127 | set the topic/description/message of the day for a channel 128 | /whoami, /key 129 | display your local user key 130 | /whois 131 | display the public keys associated with the passed in nick 132 | 133 | alt-n 134 | move between channels/cabals panes 135 | ctrl-{n,p} 136 | move up/down channels/cabals 137 | ``` 138 | 139 | ## Hotkeys 140 | `ctrl-l` 141 |     redraw the screen 142 | `ctrl-u` 143 |     clear input line 144 | `ctrl-w` 145 |     delete last word in input 146 | `up-arrow` 147 |     cycle through command history 148 | `down-arrow` 149 |     cycle through command history 150 | `home` 151 |     go to start of input line 152 | `end` 153 |     go to end of input line 154 | `ctrl-n` 155 |     go to next channel 156 | `ctrl-p` 157 |     go to previous channel 158 | `ctrl-a` 159 |     go to next unread channel 160 | `pageup` 161 |     scroll up through backlog 162 | `pagedown` 163 |     scroll down through backlog 164 | `shift-pageup` 165 |     scroll up through nicklist 166 | `shift-pagedown` 167 |     scroll down through nicklist 168 | `alt-[1,9]` 169 |     select channels 1-9 170 | `alt-n` 171 |     tab between the cabals & channels panes 172 | `alt-l` 173 |     tab toggle id suffixes on/off 174 | 175 | #### Configuration 176 | 177 | The message styling can be [slightly tweaked](https://github.com/cabal-club/cabal-cli/pull/151#issuecomment-602599840). 178 | Regarding the supported options, see [`.cabal.yml-example`](.cabal.yml-example) 179 | -------------------------------------------------------------------------------- /cli-2019-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-cli/d310fadd1209983f438e5a6b7f93b507c503781e/cli-2019-04.png -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var Client = require('cabal-client') 3 | var minimist = require('minimist') 4 | var fs = require('fs') 5 | var path = require('path') 6 | var yaml = require('js-yaml') 7 | var mkdirp = require('mkdirp') 8 | var frontend = require('./neat-screen.js') 9 | var chalk = require('chalk') 10 | var captureQrCode = require('node-camera-qr-reader') 11 | var fe = null 12 | const onExit = require('signal-exit') 13 | const { version: packageJSONVersion } = require('./package.json') 14 | 15 | var args = minimist(process.argv.slice(2)) 16 | const version = getClientVersion() 17 | 18 | // set terminal window title 19 | process.stdout.write('\x1B]0;cabal\x07') 20 | 21 | var rootdir = null 22 | if (args.config && fs.statSync(args.config).isDirectory()) { 23 | rootdir = path.join(args.config, `v${Client.getDatabaseVersion()}`) 24 | } else if (args.config) { 25 | rootdir = path.join( 26 | path.dirname(path.resolve(args.config)), 27 | `v${Client.getDatabaseVersion()}` 28 | ) 29 | } else { 30 | rootdir = Client.getCabalDirectory() 31 | } 32 | 33 | var rootconfig = `${rootdir}/config.yml` 34 | var archivesdir = `${rootdir}/archives/` 35 | 36 | const defaultMessageTimeformat = '%T' 37 | const defaultMessageIndent = 'nick' 38 | 39 | var usage = `Usage 40 | Create a new cabal: 41 | cabal --new 42 | 43 | Create a new cabal and name it locally: 44 | cabal --new --alias 45 | 46 | Join a cabal by its key: 47 | cabal cabal://key 48 | 49 | Join a cabal by an alias: 50 | cabal 51 | 52 | Save a cabal, adding it to the list of cabals to autojoin: 53 | cabal --save cabal://key 54 | 55 | Join all of your saved cabals by running just \`cabal\`: 56 | cabal 57 | 58 | Join a cabal by a QR code: 59 | cabal --qr 60 | 61 | Options: 62 | --seed Start a headless seed for the specified cabal key 63 | --port Listen for cabal traffic on the passed in port (default: 13331) 64 | 65 | --new Start a new cabal 66 | --nick Your nickname 67 | --alias Save an alias for the specified cabal. Used with --key. 68 | --alias --key 69 | --aliases Print out your saved cabal aliases 70 | --cabals Print out your saved cabals 71 | --forget Forgets the specified cabal. Works on aliases and keys persisted with --save 72 | --clear Clears out all aliases 73 | --save Save the specified cabal to the config 74 | --save 75 | --key Specify a cabal key. Used with --alias. 76 | --alias --key 77 | --config Specify a full path to a cabal config 78 | --qr Capture a frame from a connected camera to read a cabal key from a QR code 79 | 80 | --temp Start the cli with a temporary in-memory database. Useful for debugging 81 | --version Print out which version of cabal you're running 82 | --help Print this help message 83 | 84 | --message Publish a single message; then quit after \`timeout\` 85 | --channel Channel name to publish to for \`message\` option; default: "default" 86 | --timeout Delay in milliseconds to wait on swarm before quitting for \`message\` option; default: 5000 87 | --type Message type set to message for \`message\` option; default: "chat/text" 88 | 89 | Work in progress! Learn more at https://github.com/cabal-club 90 | ` 91 | 92 | if (args.version || args.v) { 93 | console.log(version) 94 | process.exit(0) 95 | } 96 | 97 | if (args.help || args.h) { 98 | process.stderr.write(usage) 99 | process.exit(1) 100 | } 101 | 102 | var config 103 | var cabalKeys = [] 104 | var configFilePath = findConfigPath() 105 | mkdirp.sync(path.dirname(configFilePath)) 106 | var maxFeeds = 1000 107 | 108 | // make sure the .cabal/v folder exists 109 | mkdirp.sync(rootdir) 110 | 111 | // create a default config in rootdir if it doesn't exist 112 | if (!fs.existsSync(rootconfig)) { 113 | saveConfig(rootconfig, { 114 | cabals: [], 115 | aliases: {}, 116 | preferredPort: 0, 117 | cache: {}, 118 | frontend: { 119 | messageTimeformat: defaultMessageTimeformat, 120 | messageIndent: defaultMessageIndent 121 | } 122 | }) 123 | } 124 | 125 | // Attempt to load local or homedir config file 126 | try { 127 | if (configFilePath) { 128 | if (fs.existsSync(configFilePath)) { 129 | config = yaml.safeLoad(fs.readFileSync(configFilePath, 'utf8')) 130 | } else { 131 | config = {} 132 | } 133 | if (!config.cabals) { config.cabals = [] } 134 | if (!config.aliases) { config.aliases = {} } 135 | if (!config.preferredPort) { config.preferredPort = 0 } 136 | if (!config.cache) { config.cache = {} } 137 | if (!config.frontend) { config.frontend = {} } 138 | if (!config.frontend.messageTimeformat) { 139 | config.frontend.messageTimeformat = defaultMessageTimeformat 140 | } 141 | if (!config.frontend.messageIndent) { 142 | config.frontend.messageIndent = defaultMessageIndent 143 | } 144 | cabalKeys = config.cabals 145 | } 146 | } catch (e) { 147 | logError(e) 148 | process.exit(1) 149 | } 150 | 151 | const client = new Client({ 152 | maxFeeds: maxFeeds, 153 | config: { 154 | dbdir: archivesdir, 155 | temp: args.temp, 156 | preferredPort: args.port || config.preferredPort 157 | }, 158 | commands: { 159 | // todo: custom commands 160 | more: { 161 | help: () => 'adds more messages to the backlog of current channel', 162 | category: ['misc'], 163 | call: (cabal, res, arg) => { 164 | fe.moreBacklog() 165 | } 166 | }, 167 | panes: { 168 | help: () => 'set pane to navigate up and down in. panes: channels, cabals', 169 | category: ['misc'], 170 | call: (cabal, res, arg) => { 171 | if (arg === '' || !['channels', 'cabals'].includes(arg)) return 172 | fe.setPane(arg) 173 | } 174 | }, 175 | quit: { 176 | help: () => 'exit the cabal process', 177 | category: ['basics'], 178 | call: (cabal, res, arg) => { 179 | process.exit(0) 180 | } 181 | }, 182 | exit: { 183 | help: () => 'exit the cabal process', 184 | category: ['basics'], 185 | call: (cabal, res, arg) => { 186 | process.exit(0) 187 | } 188 | }, 189 | help: { 190 | help: () => 'display this help message', 191 | category: ['basics'], 192 | call: (cabal, res, arg) => { 193 | const hotkeysExplanation = ` 194 | ctrl-l 195 | redraw the screen 196 | ctrl-u 197 | clear input line 198 | ctrl-w 199 | delete last word in input 200 | up-arrow 201 | cycle through command history 202 | down-arrow 203 | cycle through command history 204 | ctrl-a, home 205 | go to start of input line 206 | ctrl-e, end 207 | go to end of input line 208 | ctrl-n 209 | go to next channel 210 | ctrl-p 211 | go to previous channel 212 | ctrl-r 213 | go to next unread channel 214 | pageup 215 | scroll up through backlog 216 | pagedown 217 | scroll down through backlog 218 | shift-pageup 219 | scroll up through nicklist 220 | shift-pagedown 221 | scroll down through nicklist 222 | alt-[1,9] 223 | select channels 1-9 224 | alt-n 225 | tab between the cabals & channels panes 226 | ctrl-{n,p} 227 | move up/down channels/cabals 228 | alt-l 229 | toggle id suffixes on/off 230 | ` 231 | const categories = new Set(['hotkeys']) 232 | 233 | function printCategories () { 234 | for (const cat of Array.from(categories).sort((a, b) => a.localeCompare(b))) { 235 | fe.writeLine(`/help ${chalk.cyan(cat)}`) 236 | } 237 | } 238 | var foundAliases = {} 239 | const commands = {} 240 | for (const key in cabal.client.commands) { 241 | if (!cabal.client.commands[key].category) { continue } 242 | cabal.client.commands[key].category.forEach(cat => { 243 | if (!commands[cat]) commands[cat] = [] 244 | commands[cat].push(key) 245 | categories.add(cat) 246 | }) 247 | } 248 | if (!arg) { 249 | fe.writeLine('the help command is split into sections:') 250 | printCategories() 251 | } else if (arg && !categories.has(arg)) { 252 | fe.writeLine(`${arg} is not a help section, try:`) 253 | printCategories() 254 | } else { 255 | fe.writeLine(`help: ${chalk.cyan(arg)}`) 256 | if (arg === 'hotkeys') { 257 | fe.writeLine(hotkeysExplanation) 258 | return 259 | } 260 | // print all commands from the category defined by `arg` 261 | commands[arg].forEach(key => { 262 | if (foundAliases[key]) { return } 263 | const slash = chalk.gray('/') 264 | let command = key 265 | if (cabal.client.aliases[key]) { 266 | foundAliases[cabal.client.aliases[key]] = true 267 | command += `, ${slash}${cabal.client.aliases[key]}` 268 | } 269 | fe.writeLine(`${slash}${command}`) 270 | fe.writeLine(` ${cabal.client.commands[key].help()}`) 271 | }) 272 | } 273 | } 274 | } 275 | }, 276 | persistentCache: { 277 | read: async function (name, err) { 278 | if (name in config.cache) { 279 | var cache = config.cache[name] 280 | if (cache.expiresAt < Date.now()) { // if ttl has expired: warn, but keep using 281 | console.error(`${chalk.redBright('Note:')} the TTL for ${name} has expired`) 282 | } 283 | return cache.key 284 | } 285 | // dns record wasn't found online and wasn't in the cache 286 | return null 287 | }, 288 | write: async function (name, key, ttl) { 289 | var expireOffset = +(new Date(ttl * 1000)) // convert to epoch time 290 | var expiredTime = Date.now() + expireOffset 291 | if (!config.cache) config.cache = {} 292 | config.cache[name] = { key: key, expiresAt: expiredTime } 293 | saveConfig(configFilePath, config) 294 | } 295 | } 296 | }) 297 | 298 | // Close all cabals on exit. 299 | onExit(function () { 300 | for (const cabal of client.cabals.values()) { 301 | cabal._destroy(() => { 302 | }) 303 | } 304 | }) 305 | 306 | if (args.clear) { 307 | delete config.aliases 308 | saveConfig(configFilePath, config) 309 | process.stdout.write('Aliases cleared\n') 310 | process.exit(0) 311 | } 312 | 313 | if (args.forget) { 314 | let success = false 315 | /* eslint no-inner-declarations: "off" */ 316 | function forgetCabal (k) { 317 | const index = config.cabals.indexOf(k) 318 | if (index >= 0) { 319 | config.cabals.splice(index, 1) 320 | success = true 321 | } 322 | } 323 | if (config.aliases[args.forget]) { 324 | const aliasedKey = config.aliases[args.forget] 325 | success = true 326 | delete config.aliases[args.forget] 327 | // forget any potential reuses of the aliased key in config.cabals array 328 | forgetCabal(aliasedKey) 329 | } 330 | // check if key is among saved cabals 331 | if (!success) forgetCabal(args.forget) 332 | if (success) { 333 | saveConfig(configFilePath, config) 334 | console.log(`${args.forget} has been forgotten`) 335 | } else { console.log('no such cabal') } 336 | process.exit(0) 337 | } 338 | 339 | if (args.aliases) { 340 | var aliases = Object.keys(config.aliases) 341 | if (aliases.length === 0) { 342 | process.stdout.write("You don't have any saved aliases.\n\n") 343 | process.stdout.write('Save an alias by running\n') 344 | process.stdout.write(`${chalk.magentaBright('cabal: ')} ${chalk.greenBright('--key cabal://c001..c4b41')} `) 345 | process.stdout.write(`${chalk.blueBright('--alias your-alias-name')}\n`) 346 | } else { 347 | aliases.forEach(function (alias) { 348 | process.stdout.write(`${chalk.blueBright(alias)}\t\t ${chalk.greenBright(config.aliases[alias])}\n`) 349 | }) 350 | } 351 | process.exit(0) 352 | } 353 | 354 | if (args.cabals) { 355 | var savedCabals = config.cabals 356 | if (savedCabals.length === 0) { 357 | process.stdout.write("You don't have any saved cabals.\n\n") 358 | process.stdout.write('Save a cabal by running\n') 359 | process.stdout.write(`${chalk.magentaBright('cabal: ')} ${chalk.greenBright('--save cabal://c001..c4b41')} `) 360 | } else { 361 | savedCabals.forEach(function (saved) { 362 | process.stdout.write(`${chalk.greenBright(saved)}\n`) 363 | }) 364 | } 365 | process.exit(0) 366 | } 367 | 368 | if (args.alias && !args.new && !args.key) { 369 | logError('the --alias option needs to be used together with --key') 370 | process.exit(1) 371 | } 372 | 373 | // user wants to alias a cabal:// key with a name 374 | if (args.alias && args.key) { 375 | saveKeyAsAlias(args.key, args.alias) 376 | process.exit(0) 377 | } 378 | 379 | if (args.port) { 380 | const port = parseInt(args.port) 381 | if (isNaN(port) || port < 0 || port > 65535) { 382 | logError(`${args.port} is not a valid port number`) 383 | process.exit(1) 384 | } 385 | args.port = port 386 | } 387 | 388 | if (args.key) { 389 | // If a key is provided, place it at the top of the keys provided from the config 390 | cabalKeys.unshift(args.key) 391 | } else if (args.temp && args.temp.length > 0) { 392 | // don't eat the key if it was passed in as `cabal --temp ` 393 | cabalKeys = [args.temp] 394 | } else if (args._.length > 0) { 395 | // the cli was run as `cabal ... ` 396 | // replace keys from config with the keys from the args 397 | cabalKeys = args._.map(getKey) 398 | } 399 | 400 | // join and save the passed in cabal keys 401 | if (args.save) { 402 | cabalKeys = args._.map(getKey) 403 | if (args.save.length > 0) cabalKeys = cabalKeys.concat(getKey(args.save)) 404 | if (!cabalKeys.length) { 405 | console.log(`${chalk.magentaBright('cabal:')} error, need cabal keys to save. example:`) 406 | console.log(`${chalk.greenBright('cabal --save cabal://key')}`) 407 | process.exit(1) 408 | } 409 | 410 | config.cabals = config.cabals.concat(cabalKeys) 411 | saveConfig(configFilePath, config) 412 | // output message about keys having been saved 413 | if (cabalKeys.length === 1) { 414 | console.log(`${chalk.magentaBright('cabal:')} saved ${chalk.greenBright(cabalKeys[0])}`) 415 | } else { 416 | console.log(`${chalk.magentaBright('cabal:')} saved the following keys:`) 417 | cabalKeys.forEach((key) => { console.log(`${chalk.greenBright(key)}`) }) 418 | } 419 | process.exit(0) 420 | } 421 | 422 | // try to initiate the frontend using either qr codes via webcam, using cabal keys passed via cli, 423 | // or starting an entirely new cabal per --new 424 | if (args.qr) { 425 | console.log('Cabal is looking for a QR code...') 426 | console.log('Press ctrl-c to stop.') 427 | captureQrCode({ retry: true }).then((key) => { 428 | if (key) { 429 | console.log('\u0007') // system bell 430 | start([key], config.frontend) 431 | } else { 432 | console.log('No QR code detected.') 433 | process.exit(0) 434 | } 435 | }).catch((e) => { 436 | console.error('Webcam capture failed. Have you installed the appropriate drivers? See the documentation for more information.') 437 | console.error('Mac OSX: brew install imagesnap') 438 | console.error('Linux: sudo apt-get install fswebcam') 439 | }) 440 | } else if (cabalKeys.length || args.new) { 441 | start(cabalKeys, config.frontend) 442 | } else { 443 | // no keys, no qr, and not trying to start a new cabal => print help info 444 | process.stderr.write(usage) 445 | process.exit(1) 446 | } 447 | 448 | function start (keys, frontendConfig) { 449 | if (args.key && args.message) { 450 | publishSingleMessage({ 451 | key: args.key, 452 | channel: args.channel, 453 | message: args.message, 454 | messageType: args.type, 455 | timeout: args.timeout 456 | }) 457 | return 458 | } 459 | keys = Array.from(new Set(keys)) // remove duplicates 460 | var pendingCabals = args.new ? [client.createCabal()] : keys.map(client.addCabal.bind(client)) 461 | Promise.all(pendingCabals).then(() => { 462 | if (args.new) { 463 | console.error(`created the cabal: ${chalk.greenBright('cabal://' + client.getCurrentCabal().key)}`) // log to terminal output (stdout is occupied by interface) 464 | // allow saving newly created cabal as alias 465 | if (args.alias) { saveKeyAsAlias(client.getCurrentCabal().key, args.alias) } 466 | keys = [client.getCurrentCabal().key] 467 | } 468 | // edgecase: if the config is empty we remember the first joined cabals in it 469 | if (!config.cabals.length) { 470 | config.cabals = keys 471 | saveConfig(configFilePath, config) 472 | } 473 | if (args.nick && args.nick.length > 0) client.getCurrentCabal().publishNick(args.nick) 474 | if (!args.seed) { 475 | fe = frontend({ client, frontendConfig }) 476 | } else { 477 | const seedKeys = [] 478 | for (const details of client.cabals.keys()) { 479 | seedKeys.push(details.key) 480 | } 481 | seedKeys.forEach((k) => { 482 | console.log('Seeding', k) 483 | console.log() 484 | console.log('@: new peer') 485 | console.log('x: peer left') 486 | console.log('^: uploaded a chunk') 487 | console.log('.: downloaded a chunk') 488 | console.log() 489 | trackAndPrintEvents(client._getCabalByKey(k)) 490 | }) 491 | } 492 | }).catch((e) => { 493 | if (!e || e.toString() === 'Error: dns failed to resolve') { 494 | console.error("Error: Couldn't resolve one of the following cabal keys:", chalk.yellow(keys.join(' '))) 495 | } else { 496 | console.error(e) 497 | } 498 | process.exit(1) 499 | }) 500 | } 501 | 502 | function trackAndPrintEvents (cabal) { 503 | cabal.ready(() => { 504 | // Listen for feeds 505 | cabal.kcore._logs.feeds().forEach(listen) 506 | cabal.kcore._logs.on('feed', listen) 507 | 508 | function listen (feed) { 509 | feed.on('download', idx => { 510 | process.stdout.write('.') 511 | }) 512 | feed.on('upload', idx => { 513 | process.stdout.write('^') 514 | }) 515 | } 516 | 517 | cabal.on('peer-added', () => { 518 | process.stdout.write('@') 519 | }) 520 | 521 | cabal.on('peer-dropped', () => { 522 | process.stdout.write('x') 523 | }) 524 | }) 525 | } 526 | 527 | function getKey (str) { 528 | // return key if what was passed in was a saved alias 529 | if (str in config.aliases) { return config.aliases[str] } 530 | // else assume it's a cabal key 531 | return str 532 | } 533 | 534 | function logError (msg) { 535 | console.error(`${chalk.red('cabal:')} ${msg}`) 536 | } 537 | 538 | function findConfigPath () { 539 | var currentDirConfigFilename = '.cabal.yml' 540 | if (args.config && fs.statSync(args.config).isDirectory()) { 541 | return path.join(args.config, `v${Client.getDatabaseVersion()}`, 'config.yml') 542 | } else if (args.config && fs.existsSync(args.config)) { 543 | return args.config 544 | } else if (fs.existsSync(currentDirConfigFilename)) { 545 | return currentDirConfigFilename 546 | } 547 | return rootconfig 548 | } 549 | 550 | function saveConfig (path, config) { 551 | // make sure config is well-formatted (contains all config options) 552 | if (!config.cabals) { config.cabals = [] } 553 | config.cabals = Array.from(new Set(config.cabals)) // dedupe array entries 554 | if (!config.aliases) { config.aliases = {} } 555 | const data = yaml.safeDump(config, { 556 | sortKeys: true 557 | }) 558 | fs.writeFileSync(path, data, 'utf8') 559 | } 560 | 561 | function saveKeyAsAlias (key, alias) { 562 | config.aliases[alias] = key 563 | saveConfig(configFilePath, config) 564 | console.log(`${chalk.magentaBright('cabal:')} saved ${chalk.greenBright(key)} as ${chalk.blueBright(alias)}`) 565 | } 566 | 567 | function publishSingleMessage ({ key, channel, message, messageType, timeout }) { 568 | console.log(`Publishing message to channel - ${channel || 'default'}: ${message}`) 569 | client.addCabal(key).then(cabal => cabal.publishMessage({ 570 | type: messageType || 'chat/text', 571 | content: { 572 | channel: channel || 'default', 573 | text: message 574 | } 575 | }) 576 | ) 577 | setTimeout(function () { process.exit(0) }, timeout || 5000) 578 | } 579 | 580 | function getClientVersion () { 581 | if (packageJSONVersion) { 582 | return packageJSONVersion 583 | } 584 | console 585 | .error('failed to read cabal\'s package.json -- something is wrong with your installation') 586 | process.exit(1) 587 | } 588 | -------------------------------------------------------------------------------- /commands.js: -------------------------------------------------------------------------------- 1 | var util = require('./util') 2 | var chalk = require('chalk') 3 | var views = require('./views') 4 | 5 | function Commander (view, client) { 6 | if (!(this instanceof Commander)) return new Commander(view, client) 7 | this._hasListeners = {} 8 | this.client = client 9 | this.cabal = null 10 | this.setActiveCabal(client.getCurrentCabal()) 11 | this.view = view 12 | this.pattern = (/^\/(\w*)\s*(.*)/) 13 | this.history = [] 14 | this.historyIndex = -1 // negative: new msg, >=0: index from the last item 15 | } 16 | 17 | Commander.prototype.setActiveCabal = function (cabal) { 18 | this.cabal = cabal 19 | if (this._hasListeners[cabal.key]) return 20 | this.cabal.on('info', (msg) => { 21 | var txt = typeof msg === 'string' ? msg : (msg && msg.text ? msg.text : '') 22 | txt = util.sanitizeString(txt) 23 | const meta = msg.meta 24 | if (meta.command) { 25 | switch (meta.command) { 26 | case 'channels': 27 | if (meta.seq === 0) break // don't rewrite the payload of the first `/channels` message 28 | var { joined, channel, userCount, topic } = msg 29 | var userPart = `${userCount} ${userCount === 1 ? 'person' : 'people'}` 30 | userPart = userCount > 0 ? ': ' + chalk.cyan(userPart) : '' 31 | var maxTopicLength = views.getChatWidth() - `00:00:00 -status- ${channel}: 999 people `.length - 2 /* misc unknown padding that just makes it work v0v */ 32 | var shortTopic = topic && topic.length > maxTopicLength ? topic.slice(0, maxTopicLength - 2) + '..' : topic || '' 33 | shortTopic = util.sanitizeString(shortTopic) 34 | channel = util.sanitizeString(channel) 35 | txt = `${joined ? '*' : ' '} ${channel}${userPart} ${shortTopic}` 36 | break 37 | } 38 | } 39 | this.view.writeLine(txt) 40 | }) 41 | this.cabal.on('error', (err) => { 42 | this.view.writeLine(chalk.bold(chalk.red('! ' + util.sanitizeString(String(err))))) 43 | }) 44 | this._hasListeners[cabal.key] = true 45 | } 46 | 47 | Commander.prototype.process = function (line) { 48 | line = line.trim() 49 | this.history.push(line) 50 | this.historyIndex = -1 51 | if (this.history.length > 1000) this.history.shift() 52 | this.cabal.processLine(line) 53 | this.client.markChannelRead() 54 | } 55 | 56 | module.exports = Commander 57 | -------------------------------------------------------------------------------- /debug.js: -------------------------------------------------------------------------------- 1 | // print and simplify are debugging utils 2 | function print (t, arr, printAll) { 3 | console.error(`${t}: ${arr.length}`) 4 | if (printAll) { 5 | console.error('v'.repeat(30)) 6 | arr.map((m) => console.error(m)) 7 | console.error('^'.repeat(30)) 8 | } 9 | } 10 | 11 | function simplify (arr) { 12 | if (arr.length && arr[0].value) { 13 | return arr.map((m) => { 14 | const content = m.value.content ? m.value.content.text : JSON.stringify(m.value) 15 | return `${m.value.timestamp}: ${content}` 16 | }) 17 | } 18 | return arr 19 | } 20 | 21 | module.exports = { simplify, print } 22 | -------------------------------------------------------------------------------- /neat-screen.js: -------------------------------------------------------------------------------- 1 | var chalk = require('chalk') 2 | var Commander = require('./commands.js') 3 | var neatLog = require('neat-log') 4 | var strftime = require('strftime') 5 | var views = require('./views') 6 | var util = require('./util') 7 | var fs = require('fs') 8 | var path = require('path') 9 | var welcomePath = path.join(__dirname, 'welcome.txt') 10 | var welcomeMessage = fs.readFileSync(welcomePath).toString().split('\n') 11 | 12 | function NeatScreen (props) { 13 | if (!(this instanceof NeatScreen)) return new NeatScreen(props) 14 | this.client = props.client 15 | this.config = props.frontendConfig 16 | this.commander = Commander(this, this.client) 17 | this.lastInputTime = 0 18 | this.inputTimer = null 19 | this.BACKLOG_BATCH = 250 20 | this.additionalBacklog = 0 21 | var self = this 22 | 23 | this.neat = neatLog(this.renderApp.bind(this), { 24 | fullscreen: true, 25 | style: function (start, cursor, end) { 26 | if (!cursor) cursor = ' ' 27 | return start + chalk.underline(cursor) + end 28 | } 29 | } 30 | ) 31 | this.neat.input.on('update', () => { 32 | // debounce keyboard input events so pasting from clipboard is fast 33 | var now = Date.now() 34 | var ms = 20 35 | if (this.inputTimer) { 36 | } else if (now > this.lastInputTime + ms) { 37 | this.lastInputTime = now 38 | this.neat.render() 39 | } else { 40 | this.inputTimer = setTimeout(() => { 41 | this.inputTimer = null 42 | this.neat.render() 43 | }, ms) 44 | } 45 | }) 46 | this.neat.input.on('enter', (line) => this.commander.process(line)) 47 | 48 | // welcome to autocomplete town 49 | this.neat.input.on('tab', () => { 50 | var line = this.neat.input.rawLine() 51 | if (line.length > 1 && line[0] === '/') { 52 | const parts = line.split(/\s+/g) 53 | // command completion 54 | if (parts.length === 1) { 55 | var soFar = line.slice(1) 56 | var commands = Object.keys(this.client.getCommands()) 57 | var matchingCommands = commands.filter(cmd => cmd.startsWith(soFar)) 58 | if (matchingCommands.length === 1) { 59 | this.neat.input.set('/' + matchingCommands[0]) 60 | } 61 | // argument completion 62 | } else if (parts.length === 2) { 63 | const command = parts[0].slice(1) 64 | // we only have channel completion atm: return if command is unrelated to channels 65 | if (!['leave', 'l', 'join', 'j'].includes(command)) { return } 66 | // channel completion 67 | let channelFragment = parts[1].trim() 68 | if (this.state.prevChannelFragment && channelFragment.startsWith(this.state.prevChannelFragment)) { 69 | channelFragment = this.state.prevChannelFragment 70 | } else { 71 | // clear up old state 72 | delete this.state.prevChannelFragment 73 | delete this.state.prevChannelId 74 | } 75 | const channels = this.state.cabal.getChannels() 76 | const matches = channels.filter(ch => ch.startsWith(channelFragment)) 77 | if (matches.length === 0) { return } 78 | const chid = this.state.prevChannelId !== undefined ? (this.state.prevChannelId + 1) % matches.length : 0 79 | const channelMatch = matches[chid] 80 | this.neat.input.set(`${parts[0]} ${channelMatch}`) 81 | this.state.prevChannelId = chid 82 | this.state.prevChannelFragment = channelFragment 83 | } 84 | } else { 85 | const cabalUsers = this.client.getUsers() 86 | // nick completion 87 | const users = Object.keys(cabalUsers) 88 | .map(key => cabalUsers[key]) 89 | .sort(util.cmpUser) 90 | .map(user => user.name || user.key.substring(0, 8)) 91 | let match = line.trim().split(/\s+/g).slice(-1)[0] // usual case is we want to autocomplete the last word on a line 92 | 93 | const cursor = this.neat.input.cursor 94 | let lindex = -1 95 | let rindex = -1 96 | // cursorWandering === true => we're trying to autocomplete something in the middle of the line; i.e the cursor has wandered away from the end 97 | const cursorWandering = cursor !== line.length 98 | if (cursorWandering) { 99 | // find left-most boundary of potential nickname fragment to autocomplete 100 | for (let i = cursor - 1; i >= 0; i--) { 101 | if (line.charAt(i) === ' ' || i === 0) { 102 | lindex = i 103 | break 104 | } 105 | } 106 | // find right-most boundary of nickname 107 | for (let i = cursor; i <= line.length; i++) { 108 | if (line.charAt(i) === ' ') { 109 | rindex = i 110 | break 111 | } 112 | } 113 | match = line.slice(lindex, rindex).trim() 114 | } 115 | if (!match) { return } 116 | 117 | // determine if we are tabbing through alternatives of similar-starting nicks 118 | let cyclingNicks = false 119 | if (this.state.prevCompletion !== undefined && match.toLowerCase().startsWith(this.state.prevCompletion.toLowerCase())) { 120 | // use the original word we typed before tab-completing it 121 | match = this.state.prevCompletion 122 | cyclingNicks = true 123 | } else { 124 | delete this.state.prevCompletion 125 | delete this.state.prevNickIndex 126 | } 127 | 128 | // proceed to figure out the closest match 129 | 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 130 | if (filteredUsers.length > 0) { 131 | const userIndex = cyclingNicks ? (this.state.prevNickIndex + 1) % filteredUsers.length : 0 132 | const filteredUser = filteredUsers[userIndex] 133 | const currentInput = this.neat.input.rawLine() 134 | let completedInput = currentInput.slice(0, currentInput.length - match.length) + filteredUser 135 | // i.e. repeated tabbing of similar-starting nicks 136 | if (cyclingNicks) { 137 | let prevNick = filteredUsers[this.state.prevNickIndex] 138 | // we autocompleted a single nick w/ colon+space added, adjust for colon+space 139 | if (currentInput.length === prevNick.length + 2) { prevNick += ': ' } 140 | completedInput = currentInput.slice(0, currentInput.length - prevNick.length) + filteredUser 141 | } 142 | // i.e. cursor has been moved from end of line 143 | if (cursorWandering) { 144 | completedInput = (lindex > 0) ? currentInput.slice(0, lindex + 1) : '' 145 | completedInput += filteredUser + currentInput.slice(rindex) 146 | } 147 | // ux: we only autcompleted a single nick, add a colon and space 148 | if (completedInput === filteredUser) { 149 | completedInput += ': ' 150 | } 151 | this.neat.input.set(completedInput) // update the input line with our newly tab-completed nick 152 | // when neat-input.set() is used the cursor is automatically moved to the end of the line, 153 | // if the cursor is wandering we instead want the cursor to be just after the autocompleted name 154 | if (cursorWandering) { 155 | this.neat.input.cursor = cursor + (filteredUser.length - currentInput.slice(lindex, rindex).trim().length) 156 | } 157 | this.state.prevCompletion = match 158 | this.state.prevNickIndex = userIndex 159 | } 160 | } 161 | }) 162 | 163 | this.neat.input.on('up', () => { 164 | var i = Math.min(this.commander.history.length - 1, this.commander.historyIndex + 1) 165 | var j = this.commander.history.length - 1 - i 166 | if (j >= 0 && j < this.commander.history.length) { 167 | this.commander.historyIndex = i 168 | var command = this.commander.history[j] 169 | this.neat.input.set(command) 170 | } 171 | }) 172 | 173 | this.neat.input.on('down', () => { 174 | var len = this.commander.history.length 175 | var i = Math.max(-1, this.commander.historyIndex - 1) 176 | this.commander.historyIndex = i 177 | if (i < 0) { 178 | var line = this.neat.input.rawLine() 179 | if (line.length > 0 && line !== this.commander.history[len - 1]) { 180 | this.commander.history.push(line) 181 | } 182 | this.neat.input.set('') 183 | } else { 184 | var command = this.commander.history[len - 1 - i] 185 | this.neat.input.set(command) 186 | } 187 | }) 188 | 189 | // set channel with alt-# 190 | this.neat.input.on('alt-1', () => { setChannelByIndex(0) }) 191 | this.neat.input.on('alt-2', () => { setChannelByIndex(1) }) 192 | this.neat.input.on('alt-3', () => { setChannelByIndex(2) }) 193 | this.neat.input.on('alt-4', () => { setChannelByIndex(3) }) 194 | this.neat.input.on('alt-5', () => { setChannelByIndex(4) }) 195 | this.neat.input.on('alt-6', () => { setChannelByIndex(5) }) 196 | this.neat.input.on('alt-7', () => { setChannelByIndex(6) }) 197 | this.neat.input.on('alt-8', () => { setChannelByIndex(7) }) 198 | this.neat.input.on('alt-9', () => { setChannelByIndex(8) }) 199 | this.neat.input.on('alt-0', () => { setChannelByIndex(9) }) 200 | this.neat.input.on('alt-l', () => { this.commander.process('/ids') }) 201 | 202 | this.neat.input.on('keypress', (ch, key) => { 203 | if (!key || !key.name) return 204 | if (key.name === 'home') this.neat.input.cursor = 0 205 | else if (key.name === 'end') this.neat.input.cursor = this.neat.input.rawLine().length 206 | // clear state for nick autocompletion if something other than tab has been pressed 207 | else if (key.name !== 'tab' && this.state.prevCompletion) { 208 | delete this.state.prevCompletion 209 | delete this.state.prevNickIndex 210 | } else { 211 | return 212 | } 213 | this.bus.emit('render') 214 | }) 215 | 216 | // move between window panes with ctrl+j 217 | this.neat.input.on('alt-n', () => { 218 | var i = this.state.windowPanes.indexOf(this.state.selectedWindowPane) 219 | if (i !== -1) { 220 | i = ++i % this.state.windowPanes.length 221 | this.state.selectedWindowPane = this.state.windowPanes[i] 222 | this.bus.emit('render') 223 | } 224 | }) 225 | 226 | // move up/down pane with ctrl+{n,p} 227 | this.neat.input.on('ctrl-p', () => { 228 | cycleCurrentPane.bind(this)(-1) 229 | }) 230 | 231 | this.neat.input.on('ctrl-n', () => { 232 | cycleCurrentPane.bind(this)(1) 233 | }) 234 | 235 | // redraw the screen 236 | this.neat.input.on('ctrl-l', () => { 237 | this.neat.clear() 238 | }) 239 | 240 | // cycle to next unread channel 241 | this.neat.input.on('ctrl-r', () => { 242 | // prioritize channels with mentions. after all those are exhausted, continue to unread channels 243 | const channels = Array.from(new Set(Object.keys(this.state.mentions).concat(Object.keys(this.state.unreadChannels)))) 244 | channels.sort() 245 | if (channels.length === 0) return 246 | this.loadChannel(channels[0]) 247 | }) 248 | 249 | function cycleCurrentPane (dir) { 250 | var i 251 | if (this.state.selectedWindowPane === 'cabals') { 252 | i = this.state.cabals.findIndex((key) => key === this.state.cabal.key) 253 | i += dir * 1 254 | i = i % this.state.cabals.length 255 | if (i < 0) i += this.state.cabals.length 256 | setCabalByIndex.bind(this)(i) 257 | } else { 258 | var channels = this.state.cabal.getChannels({ includePM: true, onlyJoined: true }) 259 | i = channels.indexOf(this.state.cabal.getCurrentChannel()) 260 | i += dir * 1 261 | i = i % channels.length 262 | if (i < 0) i += channels.length 263 | setChannelByIndex.bind(this)(i) 264 | } 265 | } 266 | 267 | function setCabalByIndex (n) { 268 | if (n < 0 || n >= this.state.cabals.length) return 269 | this.showCabal(this.state.cabals[n]) 270 | } 271 | 272 | function setChannelByIndex (n) { 273 | var channels = self.state.cabal.getChannels({ includePM: true, onlyJoined: true }) 274 | if (n < 0 || n >= channels.length) return 275 | self.loadChannel(channels[n]) 276 | } 277 | 278 | const scrollOffset = 11 279 | this.neat.input.on('pageup', () => { 280 | this.state.messageScrollback += process.stdout.rows - scrollOffset 281 | }) 282 | this.neat.input.on('pagedown', () => { 283 | this.state.messageScrollback = Math.max(0, this.state.messageScrollback - (process.stdout.rows - scrollOffset)) 284 | }) 285 | this.neat.input.on('shift-pageup', () => { 286 | this.state.userScrollback = Math.max(0, this.state.userScrollback - (process.stdout.rows - 9)) 287 | }) 288 | this.neat.input.on('shift-pagedown', () => { 289 | this.state.userScrollback += process.stdout.rows - 9 290 | }) 291 | 292 | this.neat.use((state, bus) => { 293 | state.neat = this.neat 294 | this.bus = bus 295 | /* all state variables used in neat screen */ 296 | state.messages = [] 297 | state.topic = '' 298 | state.unreadChannels = {} 299 | state.mentions = {} 300 | state.selectedWindowPane = 'channels' 301 | state.windowPanes = [state.selectedWindowPane] 302 | state.config = this.config 303 | state.messageTimeLength = strftime(this.config.messageTimeformat, new Date()).length 304 | state.collision = {} 305 | this.state = state 306 | 307 | Object.defineProperty(this.state, 'cabal', { 308 | get: () => { 309 | return this.client.cabalToDetails() 310 | } 311 | }) 312 | Object.defineProperty(this.state, 'cabals', { 313 | get: () => { 314 | return this.client.getCabalKeys() 315 | } 316 | }) 317 | 318 | this.initializeCabalClient() 319 | }) 320 | } 321 | 322 | NeatScreen.prototype._handleUpdate = function (updatedDetails) { 323 | if (updatedDetails && updatedDetails.key !== this.client.getCurrentCabal().key) { 324 | // an unfocused cabal sent an update, don't render its changes 325 | return 326 | } 327 | this.state.cabal = updatedDetails 328 | var channels = this.client.getJoinedChannels() 329 | this.state.windowPanes = this.state.cabals.length > 1 ? ['channels', 'cabals'] : ['channels'] 330 | this._updateCollisions() 331 | // reset cause we fill them up below 332 | this.state.unreadChannels = {} 333 | this.state.mentions = {} 334 | channels.forEach((ch) => { 335 | var unreads = this.client.getNumberUnreadMessages(ch) 336 | if (unreads > 0) { this.state.unreadChannels[ch] = unreads } 337 | var mentions = this.client.getMentions(ch) 338 | if (mentions.length > 0) { this.state.mentions[ch] = mentions } 339 | }) 340 | this.state.topic = this.state.cabal.getTopic() 341 | var opts = {} 342 | if (!this.messageScrollback > 0) { // only update view with messages if we're at the bottom i.e. not paging up 343 | this.processMessages(opts) 344 | } 345 | this.bus.emit('render') 346 | this.updateTimer = null 347 | } 348 | 349 | NeatScreen.prototype.initializeCabalClient = function () { 350 | var details = this.client.getCurrentCabal() 351 | this.state.cabal = details 352 | this.state.messageScrollback = 0 353 | this.state.userScrollback = 0 354 | this.client.getCabalKeys().forEach((key) => { 355 | welcomeMessage.map((m) => this.client.getDetails(key).addStatusMessage({ text: m }, '!status')) 356 | 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' } })) 357 | if (this.state.moderationKeys.length > 0) { 358 | const moderationMessage = [ 359 | 'you joined via a moderation key, meaning you are allowing someone else to help administer moderation on your behalf.'] 360 | // 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 361 | // moderationMessage.push('if you would like to remove the applied moderation keys, type:') 362 | // this.state.moderationKeys.forEach((k) => { 363 | // moderationMessage.push(`/un${k.type} ${k.key}`) 364 | // }) 365 | moderationMessage.push('for more information, type /moderation') 366 | moderationMessage.forEach((text) => { 367 | this.client.getDetails(key).addStatusMessage({ text }, '!status') 368 | }) 369 | } 370 | }) 371 | this.bus.emit('render') 372 | this.registerUpdateHandler(details) 373 | this.loadChannel('!status') 374 | } 375 | 376 | // check for collisions in the first four hex chars of the users in the cabal. used in NeatScreen.prototype.formatMessage 377 | NeatScreen.prototype._updateCollisions = function () { 378 | this.state.collision = {} 379 | const userKeys = Object.keys(this.state.cabal.getUsers()) 380 | userKeys.forEach((u) => { 381 | const collision = typeof this.state.collision[u.slice(0, 4)] !== 'undefined' 382 | // if there is a collision in the first 4 chars of a pub key in the cabal, 383 | // expand it to the largest length that lets us disambiguate between the colliding ids 384 | this.state.collision[u.slice(0, 4)] = { collision, idlen: (collision ? util.unambiguous(userKeys, u) : 4) } 385 | }) 386 | } 387 | 388 | NeatScreen.prototype.registerUpdateHandler = function (cabal) { 389 | if (!this._updateHandler) this._updateHandler = {} 390 | if (this._updateHandler[cabal.key]) return // we already have a handler for that cabal 391 | this._updateHandler[cabal.key] = (updatedDetails) => { 392 | // insert timeout handler for to debounce events when tons are streaming in 393 | if (this.updateTimer) clearTimeout(this.updateTimer) 394 | this.updateTimer = setTimeout(() => { 395 | // update view 396 | this._handleUpdate(updatedDetails) 397 | }, 20) 398 | } 399 | // register an event handler for all updates from the cabal 400 | cabal.on('update', this._updateHandler[cabal.key]) 401 | // create & register event handlers for channel archiving events 402 | const processChannelArchiving = (type, { channel, reason, key, isLocal }) => { 403 | const issuer = this.client.getUsers()[key] 404 | if (!issuer || isLocal) { return } 405 | reason = reason ? `(${chalk.cyan('reason:')} ${reason})` : '' 406 | const issuerName = issuer && issuer.name ? issuer.name : key.slice(0, 8) 407 | const action = type === 'archive' ? 'archived' : 'unarchived' 408 | const text = `${issuerName} ${chalk.magenta(action)} channel ${chalk.cyan(channel)} ${reason}` 409 | this.client.addStatusMessage({ text }) 410 | this.bus.emit('render') 411 | } 412 | cabal.on('channel-archive', (envelope) => { processChannelArchiving('archive', envelope) }) 413 | cabal.on('channel-unarchive', (envelope) => { processChannelArchiving('unarchive', envelope) }) 414 | 415 | cabal.on('private-message', (envelope) => { 416 | // never display PMs inline from a hidden user 417 | if (envelope.author.isHidden()) return 418 | // don't display the notif if we're just sending something to ourselves (covered by publish-private-message event) 419 | if (envelope.author.key === cabal.getLocalUser().key) return 420 | // don't display the notification if we're already looking at the pm it came from 421 | if (cabal.getCurrentChannel() === envelope.channel) { return } 422 | const text = `PM [${envelope.author.name}]: ${envelope.message.value.content.text}` 423 | this.client.addStatusMessage({ text: chalk.magentaBright(text) }) 424 | }) 425 | 426 | cabal.on('publish-private-message', message => { 427 | // don't display the notification if we're already looking at the pm it came from 428 | if (cabal.getCurrentChannel() === message.content.channel) { return } 429 | const users = cabal.getUsers() 430 | const pubkey = message.content.channel 431 | let name = pubkey.slice(0, 8) 432 | if (pubkey in users) { 433 | // never display PMs inline from a hidden user 434 | if (users[pubkey].isHidden()) return 435 | name = users[pubkey].name 436 | } 437 | const text = `PM to [${name}]: ${message.content.text}` 438 | this.client.addStatusMessage({ text: chalk.magentaBright(text) }) 439 | }) 440 | } 441 | 442 | NeatScreen.prototype._pagesize = function () { 443 | return views.getPageSize() 444 | } 445 | 446 | NeatScreen.prototype.processMessages = function (opts, cb) { 447 | opts = opts || {} 448 | if (!cb) cb = function () {} 449 | opts.newerThan = opts.newerThan || null 450 | opts.olderThan = opts.olderThan || Date.now() 451 | opts.amount = opts.amount || this._pagesize() * 2.5 452 | opts.amount += this.additionalBacklog 453 | 454 | // var unreadCount = this.client.getNumberUnreadMessages() 455 | this.client.getMessages(opts, (msgs) => { 456 | this.state.messages = [] 457 | msgs.forEach((msg, i) => { 458 | const user = this.client.getUsers()[msg.key] 459 | if (user && user.isHidden(opts.channel)) return 460 | this.state.messages.push(this.formatMessage(msg)) 461 | }) 462 | this.bus.emit('render') 463 | cb.bind(this)() 464 | }) 465 | } 466 | 467 | NeatScreen.prototype.showCabal = function (cabal) { 468 | this.state.cabal = this.client.focusCabal(cabal) 469 | this.registerUpdateHandler(this.state.cabal) 470 | this.commander.setActiveCabal(this.state.cabal) 471 | this.client.focusChannel() 472 | this.bus.emit('render') 473 | } 474 | 475 | NeatScreen.prototype.renderApp = function (state) { 476 | if (process.stdout.columns > 80) return views.big(state) 477 | else return views.small(state) 478 | } 479 | 480 | // use to write anything else to the screen, e.g. info messages or emotes 481 | NeatScreen.prototype.writeLine = function (text) { 482 | this.client.addStatusMessage({ text }) 483 | this.bus.emit('render') 484 | } 485 | 486 | NeatScreen.prototype.clear = function () { 487 | this.state.messages = [] 488 | this.bus.emit('render') 489 | } 490 | 491 | NeatScreen.prototype.setPane = function (pane) { 492 | this.state.selectedWindowPane = pane 493 | this.bus.emit('render') 494 | } 495 | 496 | NeatScreen.prototype.moreBacklog = function () { 497 | this.additionalBacklog += this.BACKLOG_BATCH 498 | const text = `adding ${this.BACKLOG_BATCH} messages to backlog, total extra messages: ${this.additionalBacklog}` 499 | this.client.addStatusMessage({ text }) 500 | this.processMessages() 501 | } 502 | 503 | NeatScreen.prototype.loadChannel = function (channel) { 504 | this.client.focusChannel(channel) 505 | // clear the old channel state 506 | this.state.messages = [] 507 | this.state.topic = '' 508 | this.additionalBacklog = 0 509 | 510 | this.processMessages() 511 | // load the topic 512 | this.state.topic = this.state.cabal.getTopic() 513 | } 514 | 515 | NeatScreen.prototype.render = function () { 516 | this.bus.emit('render') 517 | } 518 | 519 | NeatScreen.prototype.formatMessage = function (msg) { 520 | var highlight = false 521 | /* legend for `msg` below 522 | msg = { 523 | key: '' 524 | value: { 525 | timestamp: '' 526 | type: '' 527 | content: { 528 | text: '' 529 | } 530 | } 531 | } 532 | */ 533 | if (!msg.value.type) { msg.value.type = 'chat/text' } 534 | // virtual message type, handled by cabal-client 535 | if (msg.value.type === 'status/date-changed') { 536 | return { 537 | formatted: `${chalk.dim('day changed to ' + strftime('%e %b %Y', new Date(msg.value.timestamp)))}`, 538 | raw: msg 539 | } 540 | } 541 | if (msg.value.content && msg.value.timestamp) { 542 | const users = this.client.getUsers() 543 | const authorSource = users[msg.key] || msg 544 | 545 | let author = util.sanitizeString(authorSource.name || authorSource.key.slice(0, 8)) 546 | // add author field for later use in calculating the left-padding of multi-line messages 547 | msg.author = author 548 | var localNick = 'uninitialized' 549 | if (this.state) { localNick = this.state.cabal.getLocalName() } 550 | 551 | /* sanitize user inputs to prevent interface from breaking */ 552 | localNick = util.sanitizeString(localNick) 553 | var msgtxt = msg.value.content.text 554 | if (msg.value.type !== 'status') { 555 | msgtxt = util.sanitizeString(msgtxt) 556 | } 557 | var content = msgtxt 558 | 559 | if (localNick.length > 0 && msgtxt.indexOf(localNick) > -1 && author !== localNick) { highlight = true } 560 | 561 | if (authorSource.constructor.name === 'User') { 562 | if (authorSource.isAdmin()) author = chalk.green('@') + author 563 | else if (authorSource.isModerator()) author = chalk.green('%') + author 564 | } 565 | 566 | var color = keyToColour(msg.key) || colours[5] 567 | 568 | var timestamp = `${chalk.dim(formatTime(msg.value.timestamp, this.config.messageTimeformat))}` 569 | let authorText 570 | if (msg.value.type === 'status' || msg.value.type === 'chat/moderation') { 571 | highlight = false // never highlight from status 572 | authorText = `${chalk.dim('-')}${chalk.cyan('status')}${chalk.dim('-')}` 573 | } else { 574 | /* a user wrote a message, not the !status virtual message */ 575 | 576 | // if there is a collision in the first 4 characters of a pub key in the cabal, expand it to the largest length that 577 | // lets us disambiguate between the two ids in the collision 578 | const collision = authorSource.key && this.state.collision[authorSource.key.slice(0, 4)] 579 | const pubid = collision && authorSource.key && authorSource.key.slice(0, collision.idlen) 580 | if (pubid && this.state.cabal.showIds) { 581 | authorText = `${chalk.dim('<')}${highlight ? chalk.whiteBright(author) : chalk[color](author)}${chalk.dim('.')}${chalk.inverse(chalk.cyan(pubid))}${chalk.dim('>')}` 582 | } else { 583 | authorText = `${chalk.dim('<')}${highlight ? chalk.whiteBright(author) : chalk[color](author)}${chalk.dim('>')}` 584 | } 585 | 586 | var emote = (msg.value.type === 'chat/emote') 587 | if (pubid && emote) { 588 | authorText = `${chalk.white(author)}${this.state.cabal.showIds ? chalk.dim('.') + chalk.inverse(chalk.cyan(pubid)) : ''}` 589 | content = `${chalk.dim(msgtxt)}` 590 | } 591 | } 592 | 593 | if (msg.value.type === 'chat/topic') { 594 | content = `${chalk.dim(`* sets the topic to ${chalk.cyan(msgtxt)}`)}` 595 | } else if (msg.value.type === 'chat/moderation') { 596 | const { role, type, issuerid, receiverid } = msg.value.content 597 | const issuer = this.client.getUsers()[issuerid] 598 | const receiver = this.client.getUsers()[receiverid] 599 | let action 600 | const reason = msg.value.content.reason ? `(${chalk.cyan('reason:')} ${msg.value.content.reason})` : '' 601 | const issuerName = issuer && issuer.name ? issuer.name : issuerid.slice(0, 8) 602 | const receiverName = receiver && receiver.name ? receiver.name : receiverid.slice(0, 8) 603 | if (['admin', 'mod'].includes(role)) { 604 | action = (type === 'add' ? chalk.green('added') : chalk.red('removed')) 605 | content = `${issuerName} ${action} ${receiverName} as ${chalk.cyan(role)} ${reason}` 606 | } 607 | if (role === 'hide') { 608 | action = (type === 'add' ? chalk.red('hid') : chalk.green('unhid')) 609 | content = `${issuerName} ${action} ${receiverName} ${reason}` 610 | } 611 | } 612 | emote = (emote ? ' * ' : ' ') 613 | authorText = (highlight ? chalk.bgRed(chalk.black(authorText)) : authorText) 614 | const formattedPrefix = timestamp + emote + authorText + ' ' 615 | 616 | return { 617 | timestamp, 618 | emote, 619 | author: authorText, 620 | content, 621 | formattedPrefix, 622 | formatted: formattedPrefix + content, 623 | raw: msg 624 | } 625 | } 626 | return { 627 | formatted: chalk.cyan('unknown message type: ') + chalk.inverse(JSON.stringify(msg.value)), 628 | raw: msg 629 | } 630 | } 631 | 632 | function formatTime (t, fmt) { 633 | return strftime(fmt, new Date(t)) 634 | } 635 | 636 | function keyToColour (key) { 637 | var n = 0 638 | for (var i = 0; i < key.length; i++) { 639 | n += parseInt(key[i], 16) 640 | n = n % colours.length 641 | } 642 | return colours[n] 643 | } 644 | 645 | var colours = [ 646 | 'red', 647 | 'green', 648 | 'yellow', 649 | // 'blue', 650 | 'magenta', 651 | 'cyan', 652 | // 'white', 653 | // 'gray', 654 | 'redBright', 655 | 'greenBright', 656 | 'yellowBright', 657 | 'blueBright', 658 | 'magentaBright', 659 | 'cyanBright' 660 | // 'whiteBright' 661 | ] 662 | 663 | module.exports = NeatScreen 664 | -------------------------------------------------------------------------------- /output.js: -------------------------------------------------------------------------------- 1 | module.exports = trim 2 | 3 | function trim (s) { 4 | if (!/^\r?\n/.test(s)) return s 5 | return deindent(s) 6 | } 7 | 8 | function deindent (s) { 9 | if (!/^\r?\n/.test(s)) return s 10 | var indent = (s.match(/\n([ ]+)/m) || [])[1] || '' 11 | s = indent + s 12 | return s.split('\n') 13 | .map(l => replace(indent, l)) 14 | .join('\n') 15 | } 16 | 17 | function replace (prefix, line) { 18 | return line.slice(0, prefix.length) === prefix ? line.slice(prefix.length) : line 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cabal", 3 | "version": "15.0.2", 4 | "description": "p2p chat", 5 | "bin": { 6 | "cabal": "cli.js" 7 | }, 8 | "dependencies": { 9 | "cabal-client": "^8.0.0", 10 | "chalk": "^4.0.0", 11 | "js-yaml": "^3.13.1", 12 | "minimist": "^1.2.5", 13 | "mkdirp": "^1.0.4", 14 | "neat-log": "^3.1.0", 15 | "neato-emoji-converter": "^1.1.2", 16 | "node-camera-qr-reader": "^1.0.1", 17 | "signal-exit": "^3.0.3", 18 | "strftime": "^0.10.0", 19 | "strip-ansi": "^6.0.0", 20 | "txt-blit": "^2.0.1", 21 | "wcwidth": "^1.0.1" 22 | }, 23 | "devDependencies": { 24 | "cross-zip-cli": "^1.0.0", 25 | "mocha": "^7.1.1", 26 | "pkg": "^4.4.7", 27 | "standard": "^14.3.3" 28 | }, 29 | "scripts": { 30 | "test": "standard --fix ./*.js && mocha", 31 | "package": "rm -rf builds && npm run pkg && ./package.sh", 32 | "pkg": "pkg package.json -o builds/cabal" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/cabal-club/cabal-cli.git" 37 | }, 38 | "keywords": [ 39 | "hyperdb", 40 | "decent", 41 | "dat", 42 | "chat", 43 | "cabal", 44 | "decentralize", 45 | "p2p" 46 | ], 47 | "authors": [ 48 | "cblgh", 49 | "noffle", 50 | "hunor karamán", 51 | "karissa", 52 | "ralphtheninja" 53 | ], 54 | "license": "AGPL-3.0-or-later", 55 | "bugs": { 56 | "url": "https://github.com/cabal-club/cabal-cli/issues" 57 | }, 58 | "homepage": "https://github.com/cabal-club/cabal-cli#readme", 59 | "pkg": { 60 | "assets": [ 61 | "./node_modules/utp-native/prebuilds/**", 62 | "./node_modules/blake2b-wasm/blake2b.wasm", 63 | "./node_modules/siphash24/siphash24.wasm", 64 | "./node_modules/leveldown/prebuilds/**" 65 | ], 66 | "targets": [ 67 | "node10-linux-x64", 68 | "node10-macos-x64", 69 | "node10-win-x64" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # couldnt figure out undocumented 'output template' mode for pkg so wrote this 3 | # also need to include .node files until pkg supports including them in binary 4 | # https://github.com/zeit/pkg/issues/329 5 | 6 | NODE_ABI="node.napi" 7 | VERSION=$(node -pe "require('./package.json').version") 8 | 9 | rm -rf dist 10 | 11 | mkdir dist 12 | mkdir builds/cabal-$VERSION-linux-x64 13 | mkdir builds/cabal-$VERSION-macos-x64 14 | mkdir builds/cabal-$VERSION-win-x64 15 | 16 | mv builds/cabal-linux builds/cabal-$VERSION-linux-x64/cabal 17 | mv builds/cabal-macos builds/cabal-$VERSION-macos-x64/cabal 18 | mv builds/cabal-win.exe builds/cabal-$VERSION-win-x64/cabal.exe 19 | 20 | cp node_modules/utp-native/prebuilds/linux-x64/$NODE_ABI.node builds/cabal-$VERSION-linux-x64/ 21 | cp node_modules/utp-native/prebuilds/darwin-x64/$NODE_ABI.node builds/cabal-$VERSION-macos-x64/ 22 | cp node_modules/utp-native/prebuilds/win32-x64/$NODE_ABI.node builds/cabal-$VERSION-win-x64/ 23 | 24 | cp node_modules/leveldown/prebuilds/linux-x64/$NODE_ABI.node builds/cabal-$VERSION-linux-x64/ 25 | cp node_modules/leveldown/prebuilds/darwin-x64/$NODE_ABI.node builds/cabal-$VERSION-macos-x64/ 26 | cp node_modules/leveldown/prebuilds/win32-x64/$NODE_ABI.node builds/cabal-$VERSION-win-x64/ 27 | 28 | cp LICENSE builds/cabal-$VERSION-linux-x64/ 29 | cp LICENSE builds/cabal-$VERSION-macos-x64/ 30 | cp LICENSE builds/cabal-$VERSION-win-x64/ 31 | 32 | cp README.md builds/cabal-$VERSION-linux-x64/README 33 | cp README.md builds/cabal-$VERSION-macos-x64/README 34 | cp README.md builds/cabal-$VERSION-win-x64/README 35 | 36 | cd builds 37 | ../node_modules/.bin/cross-zip cabal-$VERSION-linux-x64 ../dist/cabal-$VERSION-linux-x64.zip 38 | ../node_modules/.bin/cross-zip cabal-$VERSION-macos-x64 ../dist/cabal-$VERSION-macos-x64.zip 39 | ../node_modules/.bin/cross-zip cabal-$VERSION-win-x64 ../dist/cabal-$VERSION-win-x64.zip 40 | 41 | rm -rf builds 42 | 43 | # now travis will upload the 3 zips in dist to the release -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # publish the mirrored `cabal-cli` repo 4 | sed -i 's/"cabal"/"cabal-cli"/g' package.json 5 | npm publish 6 | sed -i 's/"cabal-cli"/"cabal"/g' package.json 7 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | 3 | describe('util', function () { 4 | var util = require('../util') 5 | describe('sanitizeString', function () { 6 | it('should escape unicode Emoji', function () { 7 | assert.equal(util.sanitizeString('🐔™ and numbers: 123'), ':chicken:™ and numbers: 123') 8 | }) 9 | it('should remove ANSI escape sequences', function () { 10 | assert.equal(util.sanitizeString('\u001b[32mHello, world!\u001b[39m'), 'Hello, world!') 11 | }) 12 | it('should keep newline but remove carriage return', function () { 13 | assert.equal(util.sanitizeString('hello\r\nworld'), 'hello\nworld') 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | var stripAnsi = require('strip-ansi') 2 | var wcwidth = require('wcwidth') 3 | var EmojiConverter = require('neato-emoji-converter') 4 | var emojiConverter = new EmojiConverter() 5 | 6 | function log (err, result) { 7 | if (err) { console.error('failed with', err) } 8 | if (arguments.length >= 2) { console.log(result) } 9 | } 10 | 11 | // return the most suitable moderation key. 12 | // if we don't have one set, default to the current user's key. 13 | // if we have joined via multiple keys, pick the first admin key. 14 | // if we don't have any admin keys, but we do have a mod key, use that instead 15 | // only return one, due to excessively long keys ':) 16 | function getModerationKey (state) { 17 | let moderationKey = state.cabal.user ? `?admin=${state.cabal.user.key}` : '' 18 | if (state.moderationKeys.length > 0) { 19 | // if admin key is set, it will be at the top. otherwise we'll set a mod key 20 | const key = state.moderationKeys[0] 21 | moderationKey = `?${key.type}=${key.key}` 22 | } 23 | return moderationKey 24 | } 25 | 26 | function sanitizeString (str) { 27 | // some emoji break the cli: replace them with shortcodes 28 | str = emojiConverter.replaceUnicode(str) 29 | str = stripAnsi(str) // strip non-visible sequences 30 | /* eslint no-control-regex: "off" */ 31 | return str.replace(/[\u0000-\u0009]|[\u000b-\u001f]/g, '') // keep newline (aka LF aka ascii character 10 aka \u000a) 32 | } 33 | 34 | // Character-wrap text containing ANSI escape codes. 35 | // String, Int -> [String] 36 | function wrapAnsi (text, width) { 37 | if (!text) return [] 38 | 39 | text = sanitizeString(text) 40 | 41 | var res = [] 42 | var line = '' 43 | var lineWidth = 0 44 | var insideCode = false 45 | var insideWord = false 46 | for (var i = 0; i < text.length; i++) { 47 | var chr = text.charAt(i) 48 | if (chr.charCodeAt(0) === 27) { 49 | insideCode = true 50 | } 51 | 52 | insideWord = !(chr.charCodeAt(0) === 32 || chr.charCodeAt(0) === 10) // ascii code for the SPACE character || NEWLINE character 53 | 54 | if (chr !== '\n') { 55 | line += chr 56 | } 57 | 58 | if (!insideCode) { 59 | lineWidth += wcwidth(text.charAt(i)) 60 | if (chr === '\n') { 61 | res.push(line) 62 | line = '' 63 | lineWidth = 0 64 | } else if (lineWidth > width) { 65 | line = line.slice(0, line.length - 1); i-- // Don't include the char that brought us over the width; reuse it 66 | const breakpoint = line.lastIndexOf(' ') 67 | if (insideWord && breakpoint >= 0) { 68 | res.push(line.slice(0, breakpoint)) // grab the first part of the line and push its str as a result 69 | line = line.slice(breakpoint + 1) // take the part after the breakpoint and add to new line 70 | lineWidth = line.length 71 | } else { 72 | res.push(line) 73 | line = '' 74 | lineWidth = 0 75 | } 76 | } 77 | } 78 | 79 | if (chr === 'm' && insideCode) { 80 | insideCode = false 81 | } 82 | } 83 | 84 | res.push(line) 85 | 86 | return res 87 | } 88 | 89 | // Length of 'str' sans ANSI codes 90 | function strlenAnsi (str) { 91 | var len = 0 92 | var insideCode = false 93 | 94 | for (var i = 0; i < str.length; i++) { 95 | var chr = str.charAt(i) 96 | if (chr.charCodeAt(0) === 27) insideCode = true 97 | if (!insideCode) len++ 98 | if (chr === 'm' && insideCode) insideCode = false 99 | } 100 | 101 | return len 102 | } 103 | 104 | // Returns the horizontal visual extent (# of fixed-width chars) a string takes 105 | // up, taking ANSI escape codes into account. Assumes a UTF-8 encoded string. 106 | function strwidth (str) { 107 | return wcwidth(stripAnsi(str)) 108 | } 109 | 110 | function centerText (text, width) { 111 | var left = Math.floor((width - strwidth(text)) / 2) 112 | var right = Math.ceil((width - strwidth(text)) / 2) 113 | var lspace = left > 0 ? new Array(left).fill(' ').join('') : '' 114 | var rspace = right > 0 ? new Array(right).fill(' ').join('') : '' 115 | return lspace + text + rspace 116 | } 117 | 118 | function rightAlignText (text, width) { 119 | var left = width - strwidth(text) 120 | if (left < 0) return text 121 | var lspace = new Array(left).fill(' ').join('') 122 | return lspace + text 123 | } 124 | 125 | // find the shortest length that is unambiguous when matching `key` for each entry in `keys` 126 | function unambiguous (keys, key) { 127 | var n = 0 128 | for (var i = 0; i < keys.length; i++) { 129 | var k = keys[i] 130 | if (key === k) continue 131 | var len = Math.min(k.length, key.length) 132 | for (var j = 0; j < len; j++) { 133 | n = Math.max(n, j) 134 | if (key.charAt(j) !== k.charAt(j)) break 135 | } 136 | } 137 | return n + 1 138 | } 139 | 140 | function wrapStatusMsg (m) { 141 | return { 142 | key: 'status', 143 | value: { 144 | timestamp: Date.now(), 145 | type: 'chat/text', 146 | content: { 147 | text: m 148 | } 149 | } 150 | } 151 | } 152 | 153 | function cmpUser (a, b) { 154 | if (!a.isHidden() && b.isHidden()) return -1 155 | if (!b.isHidden() && a.isHidden()) return 1 156 | if (a.online && !b.online) return -1 157 | if (b.online && !a.online) return 1 158 | if (a.isAdmin() && !b.isAdmin()) return -1 159 | if (b.isAdmin() && !a.isAdmin()) return 1 160 | if (a.isModerator() && !b.isModerator()) return -1 161 | if (b.isModerator() && !a.isModerator()) return 1 162 | if (a.name && !b.name) return -1 163 | if (b.name && !a.name) return 1 164 | if (a.name && b.name) return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 165 | return a.key < b.key ? -1 : 1 166 | } 167 | 168 | module.exports = { cmpUser, log, wrapAnsi, strlenAnsi, centerText, rightAlignText, wrapStatusMsg, sanitizeString, unambiguous, getModerationKey, strwidth } 169 | -------------------------------------------------------------------------------- /views.js: -------------------------------------------------------------------------------- 1 | var output = require('./output') 2 | var chalk = require('chalk') 3 | var blit = require('txt-blit') 4 | var util = require('./util') 5 | var version = require('./package.json').version 6 | 7 | const HEADER_ROWS = 8 8 | const NICK_COLS = 15 9 | const CHAN_COLS = 16 10 | 11 | module.exports = { big, small, getPageSize, getChatWidth } 12 | 13 | function getPageSize () { 14 | return process.stdout.rows - HEADER_ROWS 15 | } 16 | 17 | function getChatWidth () { 18 | if (process.stdout.columns > 80) { 19 | return process.stdout.columns - NICK_COLS - CHAN_COLS - 2 /* 2x vertical dividers */ - 1 /* nick col padding */ 20 | } 21 | return process.stdout.columns 22 | } 23 | 24 | function small (state) { 25 | var screen = [] 26 | var titlebarSize = Math.ceil(linkSize(state) / process.stdout.columns) 27 | // title bar 28 | blit(screen, renderTitlebar(state, process.stdout.columns), 0, titlebarSize - 1) 29 | // chat messages 30 | blit(screen, renderMessages(state, process.stdout.columns, process.stdout.rows - HEADER_ROWS), 0, 3) 31 | // horizontal dividers 32 | blit(screen, renderHorizontalLine('─', process.stdout.columns, chalk.blue), 0, process.stdout.rows - 2) 33 | blit(screen, renderHorizontalLine('─', process.stdout.columns, chalk.blue), 0, titlebarSize + 1) 34 | // user input prompt 35 | blit(screen, renderPrompt(state), 0, process.stdout.rows - 1) 36 | return output(screen.join('\n')) 37 | } 38 | 39 | function big (state) { 40 | var screen = [] 41 | // title bar 42 | blit(screen, renderTitlebar(state, process.stdout.columns), 0, 0) 43 | 44 | if (state.cabals.length > 1) { 45 | // cabals pane 46 | blit(screen, renderCabals(state, 6, process.stdout.rows - HEADER_ROWS), 0, process.stdout.rows - 3) 47 | } 48 | // channels listing 49 | blit(screen, renderChannels(state, CHAN_COLS, process.stdout.rows - HEADER_ROWS), 0, 3) 50 | blit(screen, renderVerticalLine('│', process.stdout.rows - 7, chalk.blue), 16, 3) 51 | 52 | // channel topic description 53 | blit(screen, renderChannelTopic(state, process.stdout.columns - 16 - 17, process.stdout.rows - HEADER_ROWS), 17, 3) 54 | // chat messages 55 | blit(screen, renderMessages(state, process.stdout.columns - 17 - 17, process.stdout.rows - HEADER_ROWS), 17, 4) 56 | 57 | // nicks pane 58 | blit(screen, renderVerticalLine('│', process.stdout.rows - 7, chalk.blue), process.stdout.columns - 17, 3) 59 | blit(screen, renderNicks(state, NICK_COLS, process.stdout.rows - HEADER_ROWS), process.stdout.columns - 15, 3) 60 | 61 | // horizontal dividers 62 | blit(screen, renderHorizontalLine('─', process.stdout.columns, chalk.blue), 0, process.stdout.rows - 4) 63 | blit(screen, renderHorizontalLine('─', process.stdout.columns, chalk.blue), 0, 2) 64 | 65 | // user input prompt 66 | blit(screen, renderPrompt(state), 0, process.stdout.rows - 2) 67 | 68 | return output(screen.join('\n')) 69 | } 70 | 71 | function linkSize (state) { 72 | const moderationKey = util.getModerationKey(state) 73 | if (state.cabal.key) return `cabal://${state.cabal.key.toString('hex')}`.length + moderationKey.length 74 | else return 'cabal://...' 75 | } 76 | 77 | function renderPrompt (state) { 78 | var name = util.sanitizeString(state.cabal ? state.cabal.getLocalName() : 'unknown') 79 | var channel = state.cabal.getCurrentChannel() 80 | var channelName = channel 81 | if (state.cabal.isChannelPrivate(channel)) { 82 | const recipient = state.cabal.getUsers()[channelName] 83 | const recipientName = recipient.name || recipient.key.slice(0, 8) 84 | channelName = 'pm with ' + recipientName 85 | } 86 | return [ 87 | `[${chalk.cyan(name)}:${channelName}] ${state.neat.input.line()}` 88 | ] 89 | } 90 | 91 | function renderTitlebar (state, width) { 92 | const moderationKey = chalk.cyan(util.getModerationKey(state)) 93 | return [ 94 | chalk.bgBlue(util.centerText(chalk.whiteBright.bold(`CABAL@${version}`), width)), 95 | util.rightAlignText(`cabal://${state.cabal.key.toString('hex')}${moderationKey}`, width) 96 | ] 97 | } 98 | 99 | function renderCabals (state, width, height) { 100 | return ['[' + state.cabals.map(function (cabal, idx) { 101 | var key = cabal 102 | var keyTruncated = key.substring(0, 6) 103 | // if we're dealing with the active/focused cabal 104 | if (state.cabal.key === key) { 105 | if (state.selectedWindowPane === 'cabals') { 106 | return `(${chalk.bgBlue(keyTruncated)})` 107 | } else { 108 | return `(${chalk.cyan(keyTruncated)})` 109 | } 110 | } else { 111 | return chalk.white(keyTruncated) 112 | } 113 | }).join(' ') + ']'] 114 | } 115 | 116 | function renderChannels (state, width, height) { 117 | const channels = state.cabal.getChannels({ includePM: true, onlyJoined: true }) 118 | const numPrefixWidth = String(channels.length).length 119 | 120 | const users = state.cabal.getUsers() 121 | return channels 122 | .map((channel, idx) => { 123 | const isPrivate = state.cabal.isChannelPrivate(channel) 124 | var channelTruncated = channel.substring(0, width - 5) 125 | if (isPrivate) { 126 | // if private, `channel` contains the pubkey of who we are chatting with 127 | channelTruncated = `+${getPrintedName(users[channel])}` 128 | } 129 | var unread = channel in state.unreadChannels 130 | var mentioned = channel in state.mentions 131 | 132 | const channelIdx = idx + 1 133 | let numPrefix = channelIdx + '. ' 134 | const numLength = String(channelIdx).length 135 | if (numLength < numPrefixWidth) { 136 | numPrefix += new Array(numLength).fill(' ').join('') 137 | } 138 | numPrefix = chalk.cyan(numPrefix) 139 | 140 | if (state.cabal.getCurrentChannel() === channel) { 141 | var fillWidth = width - channelTruncated.length - 5 142 | var fill = (fillWidth > 0) ? new Array(fillWidth).fill(' ').join('') : '' 143 | if (isPrivate) return ' ' + chalk.whiteBright(chalk.bgMagenta(numPrefix + channelTruncated + fill)) 144 | if (state.selectedWindowPane === 'channels') { 145 | return ' ' + chalk.whiteBright(chalk.bgBlue(numPrefix + channelTruncated + fill)) 146 | } else { 147 | return ' ' + chalk.bgBlue(numPrefix + channelTruncated + fill) 148 | } 149 | } else { 150 | if (mentioned) return ' ' + numPrefix + '@' + chalk.magenta(channelTruncated) 151 | else if (unread) return ' ' + numPrefix + '*' + chalk.green(channelTruncated) 152 | else if (isPrivate) return ' ' + numPrefix + chalk.cyan(channelTruncated) 153 | else return ' ' + numPrefix + channelTruncated 154 | } 155 | }).slice(0, height) 156 | } 157 | 158 | function renderVerticalLine (chr, height, chlk) { 159 | return new Array(height).fill(chlk ? chlk(chr) : chr) 160 | } 161 | 162 | function renderHorizontalLine (chr, width, chlk) { 163 | var txt = new Array(width).fill(chr).join('') 164 | if (chlk) txt = chlk(txt) 165 | return [txt] 166 | } 167 | 168 | function getPrintedName (user) { 169 | if (user && user.name) return user.name 170 | else return user.key.slice(0, 8) 171 | } 172 | 173 | function renderNicks (state, width, height) { 174 | // All known users 175 | var users = state.cabal.getChannelMembers() 176 | const currentChannel = state.cabal.getCurrentChannel() 177 | users = Object.keys(users) 178 | .map(key => users[key]) 179 | .sort(util.cmpUser) 180 | 181 | // Count how many occurances of same nickname there are 182 | const onlineNickCount = {} 183 | const offlineNickCount = {} 184 | users.forEach(user => { 185 | const name = getPrintedName(user) 186 | if (user.online) onlineNickCount[name] = name in onlineNickCount ? onlineNickCount[name] + 1 : 1 187 | else offlineNickCount[name] = name in offlineNickCount ? offlineNickCount[name] + 1 : 1 188 | }) 189 | 190 | // Format and colorize names 191 | const seen = {} 192 | const formattedNicks = users 193 | .filter(user => { 194 | const name = getPrintedName(user) 195 | if (seen[name]) return false 196 | seen[name] = true 197 | return true 198 | }) 199 | .map(user => { 200 | const name = getPrintedName(user) 201 | let outputName 202 | 203 | // Duplicate nick count 204 | const duplicates = user.online ? onlineNickCount[name] : offlineNickCount[name] 205 | const dupecountStr = `(${duplicates})` 206 | const modSigilLength = (user.isAdmin(currentChannel) || user.isModerator(currentChannel) || user.isHidden(currentChannel)) ? 1 : 0 207 | outputName = util.sanitizeString(name).slice(0, width - modSigilLength) 208 | if (duplicates > 1) outputName = outputName.slice(0, width - dupecountStr.length - 2 - modSigilLength) 209 | 210 | // Colorize 211 | let colorizedName = outputName.slice() 212 | if (user.isAdmin(currentChannel)) colorizedName = chalk.green('@') + colorizedName 213 | else if (user.isModerator(currentChannel)) colorizedName = chalk.green('%') + colorizedName 214 | else if (user.isHidden(currentChannel)) colorizedName = chalk.green('-') + colorizedName 215 | if (user.online) { 216 | colorizedName = chalk.bold(colorizedName) 217 | } 218 | if (duplicates > 1) colorizedName += ' ' + chalk.green(dupecountStr) 219 | return colorizedName 220 | }) 221 | 222 | // Scrolling Rendering 223 | state.userScrollback = Math.min(state.userScrollback, formattedNicks.length - height) 224 | if (formattedNicks.length < height) state.userScrollback = 0 225 | var nickBlock = formattedNicks.slice(state.userScrollback, state.userScrollback + height) 226 | return nickBlock 227 | } 228 | 229 | function renderChannelTopic (state, width, height) { 230 | var topic = state.topic || state.channel 231 | var line = topic ? '➤ ' + topic : '' 232 | line = line.substring(0, width - 1) 233 | if (line.length === width - 1) { 234 | line = line.substring(0, line.length - 1) + '…' 235 | } 236 | line = line + new Array(width - line.length - 1).fill(' ').join('') 237 | 238 | const isPrivate = state.cabal.isChannelPrivate(state.cabal.channel) 239 | // visually distinguish private channel from all other channels 240 | if (isPrivate) { return [chalk.whiteBright(chalk.bgMagenta(line))] } else { return [chalk.whiteBright(chalk.bgBlue(line))] } 241 | } 242 | 243 | function renderMessages (state, width, height) { 244 | var msgs = state.messages 245 | 246 | // Character-wrap to area edge 247 | var allLines = msgs.reduce(function (accum, msg) { 248 | // Status message 249 | if (!msg.timestamp) { 250 | // TODO(kira): These don't wrap yet & ought to! 251 | accum.push(' * ' + msg.formatted) 252 | return accum 253 | } 254 | 255 | const indent = util.strwidth(msg.formattedPrefix) 256 | 257 | const lines = util.wrapAnsi(msg.content, width - indent) 258 | if (lines.length === 0) return accum 259 | 260 | const firstLine = msg.formattedPrefix + lines[0] 261 | accum.push(firstLine) 262 | const paddedLines = lines.slice(1).map(line => ' '.repeat(indent) + line.trim()) 263 | accum = accum.concat(paddedLines) 264 | return accum 265 | }, []) 266 | 267 | // Scrollable Content 268 | 269 | state.messageScrollback = Math.min(state.messageScrollback, allLines.length - height) 270 | if (allLines.length < height) { 271 | state.messageScrollback = 0 272 | } 273 | 274 | var lines = (allLines.length < height) 275 | ? allLines.concat(Array(height - allLines.length).fill('')) 276 | : allLines.slice( 277 | allLines.length - height - state.messageScrollback, 278 | allLines.length - state.messageScrollback 279 | ) 280 | if (state.messageScrollback > 0) { 281 | lines = lines.slice(0, lines.length - 1).concat(['More messages below...']) 282 | } 283 | return lines 284 | } 285 | -------------------------------------------------------------------------------- /welcome.txt: -------------------------------------------------------------------------------- 1 | ▄▄ ▄▄▄▄ 2 | ██ ▀▀██ 3 | ▄█████▄ ▄█████▄ ██▄███▄ ▄█████▄ ██ 4 | ██▀ ▀ ▀ ▄▄▄██ ██▀ ▀██ ▀ ▄▄▄██ ██ 5 | ██ ▄██▀▀▀██ ██ ██ ▄██▀▀▀██ ██ 6 | ▀██▄▄▄▄█ ██▄▄▄███ ███▄▄██▀ ██▄▄▄███ ██▄▄▄ 7 | ▀▀▀▀▀ ▀▀▀▀ ▀▀ ▀▀ ▀▀▀ ▀▀▀▀ ▀▀ ▀▀▀▀ 8 | 9 | welcome to cabal! 10 | type /channels to see which channels to join, and /help for more commands 11 | for more info visit https://github.com/cabal-club/cabal 12 | --------------------------------------------------------------------------------