├── .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 | 
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 |
--------------------------------------------------------------------------------