Files
bare-operating-system/packages/bare-os-coreutils/lib/irc-tui.js
T
Raven Scott 3965c30352
Release rolling / release (push) Successful in 13m2s
fix IRC user list
2026-08-13 01:10:39 -04:00

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' }
}