// 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) { 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" e.eventSeq = message.eventSeq this.dispatchEvent(new CustomEvent('event', {detail: e})) 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('')) } } // ---- Utilities -------------------------------------------------------------- function utf8Encode(s) { return new TextEncoder().encode(s) } function utf8Decode(s) { return new TextDecoder().decode(s) } function hasShortcutModifiers(event) { return (event.altKey || event.escapePrefix) && !event.metaKey && !event.ctrlKey } const audioContext = new AudioContext() function beep() { let gain = audioContext.createGain() gain.gain.value = 0.5 gain.connect(audioContext.destination) let oscillator = audioContext.createOscillator() oscillator.type = "triangle" oscillator.frequency.value = 800 oscillator.connect(gain) oscillator.start(audioContext.currentTime) oscillator.stop(audioContext.currentTime + 0.1) } let iconLink = undefined let iconState = undefined function updateIcon(highlighted) { if (iconState === highlighted) return iconState = highlighted let canvas = document.createElement('canvas') canvas.width = 32 canvas.height = 32 let ctx = canvas.getContext('2d') ctx.arc(16, 16, 12, 0, 2 * Math.PI) ctx.fillStyle = '#000' if (highlighted === true) ctx.fillStyle = '#ff5f00' if (highlighted === false) ctx.fillStyle = '#ccc' ctx.fill() if (iconLink === undefined) { iconLink = document.createElement('link') iconLink.type = 'image/png' iconLink.rel = 'icon' document.getElementsByTagName('head')[0].appendChild(iconLink) } iconLink.href = canvas.toDataURL(); } // ---- Event processing ------------------------------------------------------- let rpc = new RelayRpc(proxy) let rpcEventHandlers = {} let buffers = new Map() let bufferLast = undefined let bufferCurrent = undefined let bufferLog = undefined let bufferAutoscroll = true let servers = new Map() 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 servers.clear() rpc.send({command: 'Hello', version: 1}) connecting = false m.redraw() }).catch(error => { connecting = false m.redraw() }) rpc.addEventListener('close', event => { m.redraw() }) rpc.addEventListener('event', event => { const handler = rpcEventHandlers[event.detail.event] if (handler !== undefined) { handler(event.detail) if (bufferCurrent !== undefined || event.detail.event !== 'BufferLine') m.redraw() } }) rpcEventHandlers['Ping'] = e => { rpc.send({command: 'PingResponse', eventSeq: e.eventSeq}) } // ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ rpcEventHandlers['BufferUpdate'] = e => { let b = buffers.get(e.bufferName) if (b === undefined) { buffers.set(e.bufferName, (b = { lines: [], history: [], historyAt: 0, })) bufferResetStats(b) } b.hideUnimportant = e.hideUnimportant b.kind = e.context.kind b.server = servers.get(e.context.serverName) } rpcEventHandlers['BufferStats'] = e => { let b = buffers.get(e.bufferName) if (b === undefined) return b.newMessages = e.newMessages, b.newUnimportantMessages = e.newUnimportantMessages b.highlighted = e.highlighted } rpcEventHandlers['BufferRename'] = e => { buffers.set(e.new, buffers.get(e.bufferName)) buffers.delete(e.bufferName) } rpcEventHandlers['BufferRemove'] = e => { buffers.delete(e.bufferName) if (e.bufferName === bufferLast) bufferLast = undefined } rpcEventHandlers['BufferActivate'] = e => { let old = buffers.get(bufferCurrent) if (old !== undefined) bufferResetStats(old) bufferLast = bufferCurrent let b = buffers.get(e.bufferName) bufferCurrent = e.bufferName bufferLog = undefined bufferAutoscroll = true if (b !== undefined && document.visibilityState !== 'hidden') b.highlighted = false 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) } } rpcEventHandlers['BufferLine'] = e => { let b = buffers.get(e.bufferName), line = {...e} delete line.event delete line.eventSeq 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.visibilityState !== '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++ } } if (line.isHighlight || (!visible && b.kind === 'PrivateMessage' && !line.isUnimportant)) { beep() if (!visible) b.highlighted = true } } rpcEventHandlers['BufferClear'] = e => { let b = buffers.get(e.bufferName) if (b !== undefined) b.lines.length = 0 } // ~~~ Server events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ rpcEventHandlers['ServerUpdate'] = e => { let s = servers.get(e.serverName) if (s === undefined) servers.set(e.serverName, (s = {})) s.state = e.state } rpcEventHandlers['ServerRename'] = e => { servers.set(e.new, servers.get(e.serverName)) servers.delete(e.serverName) } rpcEventHandlers['ServerRemove'] = e => { servers.delete(e.serverName) } // --- 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 --------------------------------------------------------------------- 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 highlighted = false let items = Array.from(buffers, ([name, b]) => { let classes = [], displayName = name if (name == bufferCurrent) { classes.push('current') } else if (b.newMessages) { classes.push('activity') displayName += ` (${b.newMessages})` } if (b.highlighted) { classes.push('highlighted') highlighted = true } return m('.item', { onclick: event => bufferActivate(name), class: classes.join(' '), }, displayName) }) updateIcon(rpc.ws === undefined ? null : highlighted) 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 squashing = false 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) { squashing = false } else if (squashing) { return } else { squashing = true } let date = new Date(line.when) let dateMark = date.toLocaleDateString() if (dateMark !== lastDateMark) { lines.push(m('.date', {}, dateMark)) lastDateMark = dateMark } if (squashing) { lines.push(m('.time.hidden')) lines.push(m('.content')) return } 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 Status = { view: vnode => { let b = buffers.get(bufferCurrent) if (b === undefined) return m('.status', {}, 'Synchronizing...') let status = `${bufferCurrent}` if (b.hideUnimportant) status += `` if (b.server !== undefined) status += ` (${b.server.state})` return m('.status', {}, status) }, } 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: utf8Encode( textarea.value.slice(0, textarea.selectionEnd)).length, }).then(resp => { if (!Input.stamp(textarea).every((v, k) => v === state[k])) return let preceding = utf8Encode(textarea.value).slice(0, resp.start) let start = utf8Decode(preceding).length // TODO: Somehow display remaining options, or cycle through. if (resp.completions.length) { textarea.setRangeText(resp.completions[0], start, textarea.selectionEnd, 'end') } else { beep() } 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 if (b.historyAt > 0) { if (b.historyAt == b.history.length) b.input = textarea.value textarea.value = b.history[--b.historyAt] } else { beep() } return true }, next: textarea => { let b = buffers.get(bufferCurrent) if (b === undefined) return false if (b.historyAt < b.history.length) { if (++b.historyAt == b.history.length) textarea.value = b.input else textarea.value = b.history[b.historyAt] } else { beep() } 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)]), m(Status), m(Input), ]) }, } window.addEventListener('load', () => m.mount(document.body, Main)) document.addEventListener('visibilitychange', event => { let b = buffers.get(bufferCurrent) if (b !== undefined && document.visibilityState !== 'hidden') { b.highlighted = false m.redraw() } }) // On macOS, the Alt/Option key transforms characters, which basically breaks // all event.altKey shortcuts, so implement Escape prefixing on that system. // This method of detection only works with Blink browsers, as of writing. let lastWasEscape = false document.addEventListener('keydown', event => { event.escapePrefix = lastWasEscape if (lastWasEscape) { lastWasEscape = false } else if (event.code == 'Escape' && navigator.userAgentData?.platform === 'macOS') { event.preventDefault() event.stopPropagation() lastWasEscape = true return } if (rpc.ws == undefined || !hasShortcutModifiers(event)) return // Rotate names so that the current buffer comes first. let names = [...buffers.keys()] names.push.apply(names, names.splice(0, names.findIndex(name => name == bufferCurrent))) switch (event.key) { case 'h': bufferToggleLog() break case 'a': for (const name of names.slice(1)) if (buffers.get(name).newMessages) { bufferActivate(name) break } break case '!': for (const name of names.slice(1)) if (buffers.get(name).highlighted) { bufferActivate(name) break } break case 'Tab': if (bufferLast !== undefined) bufferActivate(bufferLast) break case 'PageUp': if (names.length > 1) bufferActivate(names.at(-1)) break case 'PageDown': if (names.length > 1) bufferActivate(names.at(+1)) break default: return } event.preventDefault() }, true)