599 lines
19 KiB
JavaScript
599 lines
19 KiB
JavaScript
/** TEA IRC TUI. Cell buffer; protocol lives in irc-client.js. */
|
|
|
|
function bareIrcPad(s, w) {
|
|
s = String(s == null ? '' : s)
|
|
if (w <= 0) return ''
|
|
if (s.length > w) return s.slice(0, w)
|
|
while (s.length < w) s += ' '
|
|
return s
|
|
}
|
|
|
|
function bareIrcNickColWidth(cols, buf, roomsOpen) {
|
|
if (roomsOpen || !buf || buf.kind !== 'channel') return 0
|
|
if (cols < 80) return 0
|
|
return cols >= 140 ? 20 : 16
|
|
}
|
|
|
|
function bareIrcMemberLabel(m, key) {
|
|
var pref =
|
|
m && m.modes && m.modes.indexOf('o') >= 0
|
|
? '@'
|
|
: m && m.modes && m.modes.indexOf('v') >= 0
|
|
? '+'
|
|
: ' '
|
|
return pref + (m && m.nick ? m.nick : key)
|
|
}
|
|
|
|
function bareIrcFmtClock(ms) {
|
|
var d = new Date(typeof ms === 'number' ? ms : Date.now())
|
|
function z(n) {
|
|
return (n < 10 ? '0' : '') + n
|
|
}
|
|
return z(d.getHours()) + ':' + z(d.getMinutes())
|
|
}
|
|
|
|
function bareIrcCreateTuiApp(ctx, client) {
|
|
var tui = ctx.tui
|
|
var size0 = tui && typeof tui.size === 'function' ? tui.size() : {}
|
|
return {
|
|
client: client,
|
|
width: size0.width || 80,
|
|
height: size0.height || 24,
|
|
help: false,
|
|
quitConfirm: false,
|
|
roomsOpen: false,
|
|
roomsCursor: 0,
|
|
roomsFilterEdit: false,
|
|
openedRoomsOnce: false,
|
|
filterOpen: false,
|
|
status: 'connecting…',
|
|
input: tui.textinput.create({ prompt: '', focused: true, charLimit: 450 }),
|
|
viewport: tui.viewport.create({
|
|
width: size0.width || 80,
|
|
height: Math.max(6, (size0.height || 24) - 6)
|
|
}),
|
|
init: function () {
|
|
var self = this
|
|
client.on('registered', function () {
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('privmsg', function () {
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('notice', function () {
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('sasl', function (p) {
|
|
self.status = p && p.ok ? 'sasl ok' : 'sasl failed'
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('error', function (p) {
|
|
self.status =
|
|
'error: ' +
|
|
((p && (p.text || (p.error && p.error.message))) || 'error')
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('close', function () {
|
|
self.status = 'disconnected'
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('join', function () {
|
|
self.roomsOpen = false
|
|
self.roomsFilterEdit = false
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('list', function () {
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('listend', function (p) {
|
|
self.status = 'rooms ' + ((p && p.count) || 0)
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
client.on('motd_end', function () {
|
|
var joins = (client.opts && client.opts.autojoin) || []
|
|
if (!self.openedRoomsOnce && (!joins || !joins.length)) {
|
|
self.openedRoomsOnce = true
|
|
self.roomsOpen = true
|
|
self.roomsCursor = 0
|
|
self.status = 'pick a room (Enter join / filter Esc close)'
|
|
}
|
|
var srv = bareIrcEnsureBuffer(client.state, 'server', '*server')
|
|
bareIrcPushLine(client.state, srv, {
|
|
atMs: Date.now(),
|
|
kind: 'info',
|
|
text:
|
|
'You are ' +
|
|
(client.state.nick || 'guest') +
|
|
'. /rooms browse /join #libera help /query nick ? keys'
|
|
})
|
|
if (tui && typeof tui.send === 'function')
|
|
tui.send({ type: 'irc.paint' })
|
|
})
|
|
this._sync()
|
|
return null
|
|
},
|
|
_roomRows: function () {
|
|
var listed = this.client.state.channelList || []
|
|
if (listed.length) return listed
|
|
return BARE_IRC_FEATURED_ROOMS
|
|
},
|
|
_openRooms: function (query) {
|
|
this.roomsOpen = true
|
|
this.roomsCursor = 0
|
|
this.roomsFilterEdit = false
|
|
if (query) this.client.list(query)
|
|
this.status = query
|
|
? 'listing ' + query
|
|
: 'featured rooms (type /rooms linux to search)'
|
|
},
|
|
_joinRoomAtCursor: function () {
|
|
var rows = this._roomRows()
|
|
var hit = rows[this.roomsCursor]
|
|
if (!hit || !hit.name) return
|
|
this.roomsOpen = false
|
|
this.roomsFilterEdit = false
|
|
this.client.join(hit.name)
|
|
this.status = 'joining ' + hit.name
|
|
},
|
|
_current: function () {
|
|
var st = this.client.state
|
|
return (
|
|
st.buffers[st.currentId] || bareIrcEnsureBuffer(st, 'server', '*server')
|
|
)
|
|
},
|
|
_sync: function () {
|
|
var buf = this._current()
|
|
buf.unread = 0
|
|
buf.highlight = false
|
|
var cols = Math.max(40, this.width || 80)
|
|
var nickW = bareIrcNickColWidth(cols, buf, this.roomsOpen)
|
|
this.viewport.width = Math.max(20, cols - (nickW ? nickW + 1 : 0))
|
|
this.viewport.height = Math.max(4, (this.height || 24) - 6)
|
|
if (this.roomsOpen) {
|
|
var rows = this._roomRows()
|
|
if (this.roomsCursor >= rows.length)
|
|
this.roomsCursor = Math.max(0, rows.length - 1)
|
|
var rlines = []
|
|
var ri
|
|
for (ri = 0; ri < rows.length; ri++) {
|
|
var rr = rows[ri]
|
|
var mark = ri === this.roomsCursor ? '>' : ' '
|
|
var users =
|
|
rr.users === '' || rr.users == null
|
|
? ' '
|
|
: String(rr.users).padStart
|
|
? String(rr.users).padStart(4, ' ')
|
|
: String(rr.users)
|
|
rlines.push(
|
|
mark +
|
|
' ' +
|
|
String(rr.name || '')
|
|
.slice(0, 18)
|
|
.padEnd(18, ' ') +
|
|
' ' +
|
|
users +
|
|
' ' +
|
|
String(rr.topic || '')
|
|
)
|
|
}
|
|
if (!rlines.length) rlines.push('(no rooms — /rooms linux to search)')
|
|
this.viewport.setContent(rlines.join('\n'))
|
|
var mid = Math.floor((this.viewport.height || 8) / 2)
|
|
this.viewport.setYOffset(Math.max(0, this.roomsCursor - mid))
|
|
return
|
|
}
|
|
var lines = []
|
|
for (var i = 0; i < buf.lines.length; i++) {
|
|
var rec = buf.lines[i]
|
|
var clock = bareIrcFmtClock(rec.atMs)
|
|
if (rec.kind === 'action') {
|
|
lines.push(clock + ' * ' + rec.from + ' ' + rec.text)
|
|
} else if (rec.kind === 'notice') {
|
|
lines.push(clock + ' -' + rec.from + '- ' + rec.text)
|
|
} else if (rec.kind === 'privmsg') {
|
|
lines.push(clock + ' <' + rec.from + '> ' + rec.text)
|
|
} else {
|
|
lines.push(clock + ' ' + (rec.text || rec.command || ''))
|
|
}
|
|
}
|
|
this.viewport.setContent(lines.join('\n'))
|
|
this.viewport.gotoBottom()
|
|
},
|
|
_nextBuffer: function (dir) {
|
|
var list = bareIrcListBuffers(this.client.state)
|
|
if (!list.length) return
|
|
var cur = 0
|
|
for (var i = 0; i < list.length; i++) {
|
|
if (list[i].id === this.client.state.currentId) cur = i
|
|
}
|
|
var n = (cur + dir + list.length) % list.length
|
|
this.client.state.currentId = list[n].id
|
|
this._sync()
|
|
},
|
|
_completeNick: function () {
|
|
var buf = this._current()
|
|
var val = this.input.value || ''
|
|
var m = /(?:^|\s)(\S+)$/.exec(val)
|
|
var prefix = m ? m[1] : ''
|
|
if (!prefix) return
|
|
var mapping = bareIrcCasemapping(this.client.state.isupport)
|
|
var keys = Object.keys(buf.members || {})
|
|
var hit = ''
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var mem = buf.members[keys[i]]
|
|
var nick = mem && mem.nick ? mem.nick : keys[i]
|
|
if (
|
|
bareIrcCasemap(nick, mapping).indexOf(
|
|
bareIrcCasemap(prefix, mapping)
|
|
) === 0
|
|
) {
|
|
hit = nick
|
|
break
|
|
}
|
|
}
|
|
if (!hit) return
|
|
this.input.setValue(val.slice(0, val.length - prefix.length) + hit)
|
|
},
|
|
_applySlash: function (res) {
|
|
var buf = this._current()
|
|
if (res.help) this.help = true
|
|
if (res.rooms) this._openRooms(res.roomsQuery || '')
|
|
if (res.note) {
|
|
bareIrcPushLine(this.client.state, buf, {
|
|
atMs: Date.now(),
|
|
kind: 'info',
|
|
text: res.note
|
|
})
|
|
}
|
|
if (res.usage) {
|
|
bareIrcPushLine(this.client.state, buf, {
|
|
atMs: Date.now(),
|
|
kind: 'info',
|
|
text: res.usage
|
|
})
|
|
}
|
|
if (res.clear && buf) buf.lines = []
|
|
if (res.close) {
|
|
if (buf.kind === 'channel') this.client.part(buf.name, '')
|
|
var id = buf.id
|
|
if (id !== 'server') {
|
|
delete this.client.state.buffers[id]
|
|
this.client.state.currentId = 'server'
|
|
}
|
|
}
|
|
if (res.quit) return tui.quit
|
|
this._sync()
|
|
return null
|
|
},
|
|
_runSlash: function (line) {
|
|
var self = this
|
|
var res = bareIrcRunSlash(this.client, line, {
|
|
buf: this._current(),
|
|
ctx: ctx,
|
|
reconnect: function () {
|
|
try {
|
|
if (self.client.opts) {
|
|
self.client.attach(bareIrcDial(ctx, self.client.opts))
|
|
self.status = 'reconnecting\u2026'
|
|
}
|
|
} catch (err) {
|
|
self.status = 'reconnect failed'
|
|
}
|
|
}
|
|
})
|
|
return this._applySlash(res)
|
|
},
|
|
update: function (msg) {
|
|
if (msg && msg.type === 'resize') {
|
|
this.width = msg.width || this.width
|
|
this.height = msg.height || this.height
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
if (msg && msg.type === 'irc.paint') {
|
|
this.status = this.client.state.registered
|
|
? this.client.state.saslOk
|
|
? 'sasl ok'
|
|
: 'registered'
|
|
: this.client.state.connection
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
if (this.help) {
|
|
if (msg && msg.type === 'key') this.help = false
|
|
return [this, null]
|
|
}
|
|
if (this.roomsOpen) {
|
|
if (this.roomsFilterEdit) {
|
|
if (tui.key.matches(msg, 'escape')) {
|
|
this.roomsFilterEdit = false
|
|
this.input.reset()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'enter')) {
|
|
var q = String(this.input.value || '').trim()
|
|
this.input.reset()
|
|
this.roomsFilterEdit = false
|
|
if (q) this.client.list(q)
|
|
this.status = q ? 'listing ' + q : 'rooms'
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
var rpair = this.input.update(msg)
|
|
this.input = rpair[0]
|
|
return [this, rpair[1]]
|
|
}
|
|
if (tui.key.matches(msg, 'escape', 'q')) {
|
|
this.roomsOpen = false
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, '/', 'f')) {
|
|
this.roomsFilterEdit = true
|
|
this.input.reset()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'enter')) {
|
|
this._joinRoomAtCursor()
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'j', 'down')) {
|
|
this.roomsCursor++
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'k', 'up')) {
|
|
this.roomsCursor = Math.max(0, this.roomsCursor - 1)
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'pageup', 'pagedown')) {
|
|
var step = this.viewport.height || 10
|
|
if (tui.key.matches(msg, 'pageup'))
|
|
this.roomsCursor = Math.max(0, this.roomsCursor - step)
|
|
else this.roomsCursor += step
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
}
|
|
if (this.quitConfirm) {
|
|
if (tui.key.matches(msg, 'y', 'Y')) {
|
|
this.client.quit('Quit')
|
|
return [this, tui.quit]
|
|
}
|
|
if (msg && msg.type === 'key') this.quitConfirm = false
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+c')) {
|
|
this.quitConfirm = true
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'f10')) {
|
|
this.client.quit('Quit')
|
|
return [this, tui.quit]
|
|
}
|
|
if (tui.key.matches(msg, '?')) {
|
|
this.help = true
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+l')) {
|
|
this._openRooms('')
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+n', 'alt+right')) {
|
|
this._nextBuffer(1)
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+p', 'alt+left')) {
|
|
this._nextBuffer(-1)
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'tab')) {
|
|
this._completeNick()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'pageup', 'pagedown', 'up', 'down')) {
|
|
var vp = this.viewport.update(msg)
|
|
this.viewport = vp[0]
|
|
return [this, vp[1]]
|
|
}
|
|
if (tui.key.matches(msg, 'enter')) {
|
|
var line = String(this.input.value || '').trim()
|
|
this.input.reset()
|
|
if (!line) return [this, null]
|
|
if (line.charAt(0) === '/') {
|
|
var cmd = this._runSlash(line)
|
|
return [this, cmd]
|
|
}
|
|
var cur = this._current()
|
|
if (cur.kind === 'server') {
|
|
bareIrcPushLine(this.client.state, cur, {
|
|
atMs: Date.now(),
|
|
kind: 'info',
|
|
text: 'join a channel or /query someone first'
|
|
})
|
|
} else if (cur.kind === 'p2p') {
|
|
bareIrcPushLine(this.client.state, cur, {
|
|
atMs: Date.now(),
|
|
kind: 'privmsg',
|
|
from: this.client.state.nick || 'me',
|
|
text: line,
|
|
local: true
|
|
})
|
|
if (typeof ctx.bareOsChatSend === 'function') {
|
|
try {
|
|
ctx.bareOsChatSend(
|
|
'[bare-os-irc-shadow] ' + cur.name + ' ' + line
|
|
)
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
}
|
|
} else {
|
|
this.client.sendPrivmsg(cur.name, line)
|
|
}
|
|
this._sync()
|
|
return [this, null]
|
|
}
|
|
var pair = this.input.update(msg)
|
|
this.input = pair[0]
|
|
return [this, pair[1]]
|
|
},
|
|
view: function () {
|
|
var cols = Math.max(40, this.width || 80)
|
|
var rows = Math.max(12, this.height || 24)
|
|
var st = tui.style
|
|
var buf = this._current()
|
|
var net = this.client.state.network || 'irc'
|
|
var tls = this.client.state.tls ? 'tls' : 'plain'
|
|
var titleRaw =
|
|
' ' +
|
|
net +
|
|
' ' +
|
|
buf.name +
|
|
(buf.unread ? ' ● ' + buf.unread : '') +
|
|
' ' +
|
|
tls +
|
|
(this.client.state.saslOk ? ' sasl' : '') +
|
|
' ' +
|
|
this.status +
|
|
' ' +
|
|
cols +
|
|
'x' +
|
|
rows +
|
|
' '
|
|
var title = st
|
|
? st()
|
|
.foreground('brightwhite')
|
|
.background('blue')
|
|
.width(cols)
|
|
.render(titleRaw)
|
|
: titleRaw
|
|
var tabs = bareIrcListBuffers(this.client.state)
|
|
.map(function (b) {
|
|
var mark = b.id === buf.id ? '[' + b.name + ']' : b.name
|
|
if (b.unread) mark += '(' + b.unread + ')'
|
|
return mark
|
|
})
|
|
.join(' ')
|
|
var tabBar = st ? st().dim().width(cols).render(tabs) : tabs
|
|
var nickW = bareIrcNickColWidth(cols, buf, this.roomsOpen)
|
|
var bodyH = this.viewport.height || Math.max(4, rows - 6)
|
|
var body = this.viewport.view()
|
|
if (nickW && buf.kind === 'channel') {
|
|
var keys = Object.keys(buf.members || {})
|
|
keys.sort(function (a, b) {
|
|
var ma = buf.members[a]
|
|
var mb = buf.members[b]
|
|
var pa = ma && ma.modes && ma.modes.indexOf('o') >= 0 ? 0 : 1
|
|
var pb = mb && mb.modes && mb.modes.indexOf('o') >= 0 ? 0 : 1
|
|
if (pa !== pb) return pa - pb
|
|
var na = ma && ma.nick ? ma.nick : a
|
|
var nb = mb && mb.nick ? mb.nick : b
|
|
return na < nb ? -1 : na > nb ? 1 : 0
|
|
})
|
|
var nicks = keys.map(function (k) {
|
|
return bareIrcMemberLabel(buf.members[k], k)
|
|
})
|
|
var left = String(body).split('\n')
|
|
var chatW = Math.max(8, cols - nickW - 1)
|
|
var joined = []
|
|
var r
|
|
for (r = 0; r < bodyH; r++) {
|
|
var L = bareIrcPad(
|
|
st ? st.truncate(left[r] || '', chatW) : left[r] || '',
|
|
chatW
|
|
)
|
|
var R = bareIrcPad(nicks[r] || '', nickW)
|
|
joined.push(L + '\u2502' + R)
|
|
}
|
|
body = joined.join('\n')
|
|
}
|
|
var prompt
|
|
if (this.roomsOpen && this.roomsFilterEdit) {
|
|
prompt =
|
|
'Filter: ' +
|
|
(this.input.view ? this.input.view() : this.input.value || '')
|
|
} else {
|
|
prompt =
|
|
(buf.kind === 'p2p' ? '#p2p ' : '') +
|
|
'[' +
|
|
(this.roomsOpen ? 'rooms' : buf.name) +
|
|
'] ' +
|
|
(this.input.view ? this.input.view() : this.input.value || '')
|
|
}
|
|
var footRaw = this.roomsOpen
|
|
? this.roomsFilterEdit
|
|
? 'Filter + Enter search LIST Esc back'
|
|
: 'Enter join j/k move / filter Esc close featured or /rooms linux'
|
|
: 'Enter send /rooms /join /query Ctrl+L rooms Ctrl+n/p Tab nick ?'
|
|
var foot = st ? st().dim().width(cols).render(footRaw) : footRaw
|
|
var rule = st
|
|
? st()
|
|
.dim()
|
|
.width(cols)
|
|
.render('\u2500'.repeat(Math.min(cols, 120)))
|
|
: ''
|
|
var lines = [title, tabBar, rule]
|
|
.concat(String(body).split('\n'))
|
|
.concat([rule, prompt, foot])
|
|
while (lines.length < rows) lines.push('')
|
|
var out = []
|
|
for (var i = 0; i < rows; i++) {
|
|
out.push(
|
|
st
|
|
? st.truncate(lines[i] || '', cols)
|
|
: String(lines[i] || '').slice(0, cols)
|
|
)
|
|
}
|
|
return out.join('\n')
|
|
},
|
|
overlay: function (size) {
|
|
if (!this.help && !this.quitConfirm) return null
|
|
var cols = (size && size.width) || this.width || 80
|
|
var rows = (size && size.height) || this.height || 24
|
|
var st = tui.style
|
|
var body = this.quitConfirm
|
|
? 'Disconnect and quit?\n\nY yes any other key cancel'
|
|
: 'Bare OS irc\n\n' +
|
|
'/rooms [pat] browse/list /join #chan /part /query nick\n' +
|
|
'/msg /notice /me /nick /whois /whowas /who /names /topic\n' +
|
|
'/kick /invite /op /deop /voice /mode /ban /away /back\n' +
|
|
'/list /motd /quote /ctcp /ping /ignore /clear /close /quit\n' +
|
|
'/p2p open #chan shadow room (not Libera)\n' +
|
|
'Ctrl+L rooms Ctrl+n/p buffers Tab nick\n\n' +
|
|
'Default: irc.libera.chat:6697 TLS random nick if unset\n' +
|
|
'Press any key to close.'
|
|
var boxed = st
|
|
? st()
|
|
.border(st.borders.rounded)
|
|
.padding(1, 2)
|
|
.background('black')
|
|
.render(body)
|
|
: body
|
|
var h = st ? st.height(boxed) : boxed.split('\n').length
|
|
var w = st ? st.width(boxed) : 48
|
|
return {
|
|
row: Math.max(0, Math.floor((rows - h) / 2)),
|
|
col: Math.max(0, Math.floor((cols - w) / 2)),
|
|
text: boxed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function bareIrcTuiRunOpts() {
|
|
return { buffer: 'cell' }
|
|
}
|