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