// Copyright (c) 2022, Přemysl Eric Janouch
// SPDX-License-Identifier: 0BSD 'use strict' // ---- RPC -------------------------------------------------------------------- class RelayRpc extends EventTarget { constructor(url) { super() this.url = url this.commandSeq = 0 } connect() { // We can't close the connection immediately, as that queues a task. if (this.ws !== undefined) throw "Already connecting or connected" return new Promise((resolve, reject) => { let ws = this.ws = new WebSocket(this.url) ws.onopen = event => { this._initialize() resolve() } // It's going to be code 1006 with no further info. ws.onclose = event => { this.ws = undefined reject() } }) } _initialize() { this.ws.onopen = undefined this.ws.onmessage = event => { this._process(event.data) } this.ws.onerror = event => { this.dispatchEvent(new CustomEvent('error')) } this.ws.onclose = event => { let message = "Connection closed: " + event.code + " (" + event.reason + ")" for (const seq in this.promised) this.promised[seq].reject(message) this.ws = undefined this.dispatchEvent(new CustomEvent('close', { detail: {message, code: event.code, reason: event.reason}, })) // Now connect() can be called again. } this.promised = {} } _process(data) { console.log(data) if (typeof data !== 'string') throw "Binary messages not supported" let message = JSON.parse(data) if (typeof message !== 'object') throw "Invalid message" let e = message.data if (typeof e !== 'object') throw "Invalid message" switch (e.event) { case 'Error': if (this.promised[e.commandSeq] !== undefined) this.promised[e.commandSeq].reject(e.error) else console.error("Unawaited error") break case 'Response': if (this.promised[e.commandSeq] !== undefined) this.promised[e.commandSeq].resolve(e.data) else console.error("Unawaited response") break default: if (typeof e.event !== 'string') throw "Invalid event tag" this.dispatchEvent(new CustomEvent( e.event, {detail: {eventSeq: message.eventSeq, ...e}})) // Minor abstraction layering violation. m.redraw() return } delete this.promised[e.commandSeq] for (const seq in this.promised) { // We don't particularly care about wraparound issues. if (seq >= e.commandSeq) continue this.promised[seq].reject("No response") delete this.promised[seq] } } send(params) { if (this.ws === undefined) throw "Not connected" if (typeof params !== 'object') throw "Method parameters must be an object" // Left shifts in Javascript convert to a 32-bit signed number. let seq = ++this.commandSeq if ((seq << 0) != seq) seq = this.commandSeq = 0 this.ws.send(JSON.stringify({commandSeq: seq, data: params})) return new Promise((resolve, reject) => { this.promised[seq] = {resolve, reject} }) } base64decode(str) { return decodeURIComponent(atob(str).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')) } } // ---- Event processing ------------------------------------------------------- let rpc = new RelayRpc(proxy) let buffers = new Map() let bufferLast = undefined let bufferCurrent = undefined let bufferLog = undefined let bufferAutoscroll = true function bufferResetStats(b) { b.newMessages = 0 b.newUnimportantMessages = 0 b.highlighted = false } function bufferActivate(name) { rpc.send({command: 'BufferActivate', bufferName: name}) } function bufferToggleLog() { if (bufferLog) { setTimeout(() => document.getElementById('input')?.focus()) bufferLog = undefined m.redraw() return } let name = bufferCurrent rpc.send({ command: 'BufferLog', bufferName: name, }).then(resp => { if (bufferCurrent !== name) return bufferLog = rpc.base64decode(resp.log) m.redraw() }) } let connecting = true rpc.connect().then(result => { buffers.clear() bufferLast = undefined bufferCurrent = undefined bufferLog = undefined bufferAutoscroll = true rpc.send({command: 'Hello', version: 1}) connecting = false m.redraw() }).catch(error => { connecting = false m.redraw() }) rpc.addEventListener('close', event => { m.redraw() }) rpc.addEventListener('Ping', event => { rpc.send({command: 'PingResponse', eventSeq: event.detail.eventSeq}) }) rpc.addEventListener('BufferUpdate', event => { let e = event.detail, b = buffers.get(e.bufferName) if (b === undefined) { buffers.set(e.bufferName, (b = { lines: [], history: [], historyAt: 0, })) bufferResetStats(b) } b.hideUnimportant = e.hideUnimportant }) rpc.addEventListener('BufferStats', event => { let e = event.detail, b = buffers.get(e.bufferName) if (b === undefined) return b.newMessages = e.newMessages, b.newUnimportantMessages = e.newUnimportantMessages b.highlighted = e.highlighted }) rpc.addEventListener('BufferRename', event => { let e = event.detail buffers.set(e.new, buffers.get(e.bufferName)) buffers.delete(e.bufferName) }) rpc.addEventListener('BufferRemove', event => { let e = event.detail buffers.delete(e.bufferName) if (e.bufferName === bufferLast) bufferLast = undefined }) rpc.addEventListener('BufferActivate', event => { let old = buffers.get(bufferCurrent) if (old !== undefined) bufferResetStats(old) bufferLast = bufferCurrent let e = event.detail, b = buffers.get(e.bufferName) bufferCurrent = e.bufferName bufferLog = undefined bufferAutoscroll = true let textarea = document.getElementById('input') if (textarea === null) return textarea.focus() if (old !== undefined) { old.input = textarea.value old.inputStart = textarea.selectionStart old.inputEnd = textarea.selectionEnd old.inputDirection = textarea.selectionDirection // Note that we effectively overwrite the newest line // with the current textarea contents, and jump there. old.historyAt = old.history.length } textarea.value = '' if (b !== undefined && b.input !== undefined) { textarea.value = b.input textarea.setSelectionRange(b.inputStart, b.inputEnd, b.inputDirection) } }) rpc.addEventListener('BufferLine', event => { let e = event.detail, b = buffers.get(e.bufferName), line = {...e} delete line.event delete line.leakToActive if (b === undefined) return // Initial sync: skip all other processing, let highlights be. if (bufferCurrent === undefined) { b.lines.push(line) return } let visible = !document.hidden && bufferLog === undefined && (e.bufferName == bufferCurrent || e.leakToActive) b.lines.push({...line}) if (!(visible || e.leakToActive) || b.newMessages || b.newUnimportantMessages) { if (line.isUnimportant) b.newUnimportantMessages++ else b.newMessages++ } if (e.leakToActive) { let bc = buffers.get(bufferCurrent) bc.lines.push({...line, leaked: true}) if (!visible || bc.newMessages || bc.newUnimportantMessages) { if (line.isUnimportant) bc.newUnimportantMessages++ else bc.newMessages++ } } // TODO: Find some way of highlighting the tab in a browser. // TODO: Also highlight on unseen private messages, like xC does. if (!visible && line.isHighlight) b.highlighted = true }) rpc.addEventListener('BufferClear', event => { let e = event.detail, b = buffers.get(e.bufferName) if (b !== undefined) b.lines.length = 0 }) // --- Colours ----------------------------------------------------------------- let palette = [ '#000', '#800', '#080', '#880', '#008', '#808', '#088', '#ccc', '#888', '#f00', '#0f0', '#ff0', '#00f', '#f0f', '#0ff', '#fff', ] palette.length = 256 for (let i = 0; i < 216; i++) { let r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6 r = !r ? '00' : (55 + 40 * r).toString(16) g = !g ? '00' : (55 + 40 * g).toString(16) b = !b ? '00' : (55 + 40 * b).toString(16) palette[16 + i] = `#${r}${g}${b}` } for (let i = 0; i < 24; i++) { let g = ('0' + (8 + i * 10).toString(16)).slice(-2) palette[232 + i] = `#${g}${g}${g}` } // ---- UI --------------------------------------------------------------------- // On macOS, the Alt/Option key transforms characters, which basically breaks // all event.altKey shortcuts, so require combining them with Control as well // on that system. function hasShortcutModifiers(event) { // This method of detection only works with Blink browsers, as of writing. return event.altKey && !event.metaKey && (navigator.userAgentData?.platform === 'macOS') === event.ctrlKey } let linkRE = [ /https?:\/\//, /([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/, /[^\[\](){}<>"'\s,.:]/, ].map(r => r.source).join('') let Toolbar = { toggleAutoscroll: () => { bufferAutoscroll = !bufferAutoscroll }, view: vnode => { return m('.toolbar', {}, [ m('button', {onclick: Toolbar.toggleAutoscroll}, bufferAutoscroll ? 'Scroll lock' : 'Scroll unlock'), m('button', {onclick: event => bufferToggleLog()}, bufferLog === undefined ? 'Show log' : 'Hide log'), ]) }, } let BufferList = { view: vnode => { let items = Array.from(buffers, ([name, b]) => { let classes = [], displayName = name if (name == bufferCurrent) { classes.push('current') } else { if (b.highlighted) classes.push('highlighted') if (b.newMessages) { classes.push('activity') displayName += ` (${b.newMessages})` } } return m('.item', { onclick: event => bufferActivate(name), class: classes.join(' '), }, displayName) }) return m('.list', {}, items) }, } let Content = { applyColor: (fg, bg, inverse) => { if (inverse) [fg, bg] = [bg >= 0 ? bg : 15, fg >= 0 ? fg : 0] let style = {} if (fg >= 0) style.color = palette[fg] if (bg >= 0) style.backgroundColor = palette[bg] if (style) return style }, linkify: (text, attrs) => { let re = new RegExp(linkRE, 'g'), a = [], end = 0, match while ((match = re.exec(text)) !== null) { if (end < match.index) a.push(m('span', attrs, text.substring(end, match.index))) a.push(m('a[target=_blank]', {href: match[0], ...attrs}, match[0])) end = re.lastIndex } if (end < text.length) a.push(m('span', attrs, text.substring(end))) return a }, view: vnode => { let line = vnode.children[0] let mark = undefined switch (line.rendition) { case 'Indent': mark = m('span.mark', {}, ''); break case 'Status': mark = m('span.mark', {}, '–'); break case 'Error': mark = m('span.mark.error', {}, '⚠'); break case 'Join': mark = m('span.mark.join', {}, '→'); break case 'Part': mark = m('span.mark.part', {}, '←'); break case 'Action': mark = m('span.mark.action', {}, '✶'); break } let classes = new Set() let flip = c => { if (classes.has(c)) classes.delete(c) else classes.add(c) } let fg = -1, bg = -1, inverse = false return m('.content', vnode.attrs, [mark, line.items.flatMap(item => { switch (item.kind) { case 'Text': return Content.linkify(item.text, { class: Array.from(classes.keys()).join(' '), style: Content.applyColor(fg, bg, inverse), }) case 'Reset': classes.clear() fg = bg = -1 inverse = false break case 'FgColor': fg = item.color break case 'BgColor': bg = item.color break case 'FlipInverse': inverse = !inverse break case 'FlipBold': flip('b') break case 'FlipItalic': flip('i') break case 'FlipUnderline': flip('u') break case 'FlipCrossedOut': flip('s') break case 'FlipMonospace': flip('m') break } })]) }, } let Buffer = { controller: new AbortController(), onbeforeremove: vnode => { Buffer.controller.abort() }, onupdate: vnode => { if (bufferAutoscroll) vnode.dom.scrollTop = vnode.dom.scrollHeight }, oncreate: vnode => { Buffer.onupdate(vnode) window.addEventListener('resize', event => Buffer.onupdate(vnode), {signal: Buffer.controller.signal}) }, view: vnode => { let lines = [] let b = buffers.get(bufferCurrent) if (b === undefined) return m('.buffer') let lastDateMark = undefined let markBefore = b.lines.length - b.newMessages - b.newUnimportantMessages b.lines.forEach((line, i) => { if (i == markBefore) lines.push(m('.unread')) if (line.isUnimportant && b.hideUnimportant) return let date = new Date(line.when) let dateMark = date.toLocaleDateString() if (dateMark !== lastDateMark) { lines.push(m('.date', {}, dateMark)) lastDateMark = dateMark } let attrs = {} if (line.leaked) attrs.class = 'leaked' lines.push(m('.time', {...attrs}, date.toLocaleTimeString())) lines.push(m(Content, {...attrs}, line)) }) let dateMark = new Date().toLocaleDateString() if (dateMark !== lastDateMark && lastDateMark !== undefined) lines.push(m('.date', {}, dateMark)) return m('.buffer', {}, lines) }, } let Log = { oncreate: vnode => { vnode.dom.scrollTop = vnode.dom.scrollHeight vnode.dom.focus() }, linkify: text => { let re = new RegExp(linkRE, 'g'), a = [], end = 0, match while ((match = re.exec(text)) !== null) { if (end < match.index) a.push(text.substring(end, match.index)) a.push(m('a[target=_blank]', {href: match[0]}, match[0])) end = re.lastIndex } if (end < text.length) a.push(text.substring(end)) return a }, view: vnode => { return m(".log", {}, Log.linkify(bufferLog)) }, } let BufferContainer = { view: vnode => { return m('.buffer-container', {}, [ m('.filler'), bufferLog !== undefined ? m(Log) : m(Buffer), ]) }, } let Input = { counter: 0, stamp: textarea => { return [Input.counter, textarea.selectionStart, textarea.selectionEnd, textarea.value] }, complete: textarea => { if (textarea.selectionStart !== textarea.selectionEnd) return false // Cancel any previous autocomplete, and ensure applicability. Input.counter++ let state = Input.stamp(textarea) rpc.send({ command: 'BufferComplete', bufferName: bufferCurrent, text: textarea.value, position: textarea.selectionEnd, }).then(resp => { if (!Input.stamp(textarea).every((v, k) => v === state[k])) return // TODO: Somehow display remaining options, or cycle through. if (resp.completions.length) textarea.setRangeText(resp.completions[0], resp.start, textarea.selectionEnd, 'end') if (resp.completions.length === 1) textarea.setRangeText(' ', textarea.selectionStart, textarea.selectionEnd, 'end') }) return true }, submit: textarea => { rpc.send({ command: 'BufferInput', bufferName: bufferCurrent, text: textarea.value, }) // b.history[b.history.length] is virtual, and is represented // either by textarea contents when it's currently being edited, // or by b.input in all other cases. let b = buffers.get(bufferCurrent) b.history.push(textarea.value) b.historyAt = b.history.length textarea.value = '' return true }, previous: textarea => { let b = buffers.get(bufferCurrent) if (b === undefined) return false // TODO: Ding otherwise. if (b.historyAt > 0) { if (b.historyAt == b.history.length) b.input = textarea.value textarea.value = b.history[--b.historyAt] } return true }, next: textarea => { let b = buffers.get(bufferCurrent) if (b === undefined) return false // TODO: Ding otherwise. if (b.historyAt < b.history.length) { if (++b.historyAt == b.history.length) textarea.value = b.input else textarea.value = b.history[b.historyAt] } return true }, onKeyDown: event => { // TODO: And perhaps on other actions, too. rpc.send({command: 'Active'}) let textarea = event.currentTarget let handled = false if (hasShortcutModifiers(event)) { switch (event.key) { case 'p': handled = Input.previous(textarea) break case 'n': handled = Input.next(textarea) break } } else if (!event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { switch (event.keyCode) { case 9: handled = Input.complete(textarea) break case 13: handled = Input.submit(textarea) break } } if (handled) event.preventDefault() }, view: vnode => { return m('textarea#input', {rows: 1, onkeydown: Input.onKeyDown}) }, } let Main = { view: vnode => { let state = "Connected" if (connecting) state = "Connecting..." else if (rpc.ws === undefined) state = "Disconnected" return m('.xP', {}, [ m('.title', {}, [`xP (${state})`, m(Toolbar)]), m('.middle', {}, [m(BufferList), m(BufferContainer)]), // TODO: Indicate hideUnimportant. m('.status', {}, bufferCurrent), m(Input), ]) }, } window.addEventListener('load', () => m.mount(document.body, Main)) document.addEventListener('keydown', event => { if (rpc.ws == undefined || !hasShortcutModifiers(event)) return switch (event.key) { case 'Tab': if (bufferLast !== undefined) bufferActivate(bufferLast) break case 'h': bufferToggleLog() break case 'a': for (const [name, b] of buffers) if (name !== bufferCurrent && b.newMessages) { bufferActivate(name) break } break case '!': for (const [name, b] of buffers) if (name !== bufferCurrent && b.highlighted) { bufferActivate(name) break } break default: return } event.preventDefault() })