// Copyright (c) 2022, Přemysl Eric Janouch // SPDX-License-Identifier: 0BSD import * as Relay from './proto.js' // ---- 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.binaryType = 'arraybuffer' 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.reason + " (" + event.code + ")" 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 "JSON messages not supported" const r = new Relay.Reader(data) while (!r.empty) this._processOne(Relay.EventMessage.deserialize(r)) } _processOne(message) { let e = message.data switch (e.event) { case Relay.Event.Error: if (this.promised[e.commandSeq] !== undefined) this.promised[e.commandSeq].reject(e.error) else console.error(`Unawaited error: ${e.error}`) break case Relay.Event.Response: if (this.promised[e.commandSeq] !== undefined) this.promised[e.commandSeq].resolve(e.data) else console.error("Unawaited response") break default: 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})) // Automagically detect if we want a result. let data = undefined const promise = new Promise( (resolve, reject) => { data = {resolve, reject} }) promise.then = (...args) => { this.promised[seq] = data return Promise.prototype.then.call(promise, ...args) } return promise } } // ---- 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 = new Map() 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 bufferToggleUnimportant(name) { rpc.send({command: 'BufferToggleUnimportant', bufferName: name}) } function bufferToggleLog() { // TODO: Try to restore the previous scroll offset. 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 = utf8Decode(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: Relay.version}) connecting = false m.redraw() }).catch(error => { connecting = false m.redraw() }) rpc.addEventListener('close', event => { m.redraw() }) rpc.addEventListener('event', event => { const handler = rpcEventHandlers.get(event.detail.event) if (handler !== undefined) { handler(event.detail) if (bufferCurrent !== undefined || event.detail.event !== Relay.Event.BufferLine) m.redraw() } }) rpcEventHandlers.set(Relay.Event.Ping, e => { rpc.send({command: 'PingResponse', eventSeq: e.eventSeq}) }) // ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ rpcEventHandlers.set(Relay.Event.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 && bufferAutoscroll && (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 && !line.isUnimportant && b.kind === Relay.BufferKind.PrivateMessage)) { beep() if (!visible) b.highlighted = true } }) rpcEventHandlers.set(Relay.Event.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) b.topic = e.context.topic }) rpcEventHandlers.set(Relay.Event.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.set(Relay.Event.BufferRename, e => { buffers.set(e.new, buffers.get(e.bufferName)) buffers.delete(e.bufferName) }) rpcEventHandlers.set(Relay.Event.BufferRemove, e => { buffers.delete(e.bufferName) if (e.bufferName === bufferLast) bufferLast = undefined }) rpcEventHandlers.set(Relay.Event.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.set(Relay.Event.BufferClear, e => { let b = buffers.get(e.bufferName) if (b !== undefined) b.lines.length = 0 }) // ~~~ Server events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ rpcEventHandlers.set(Relay.Event.ServerUpdate, e => { let s = servers.get(e.serverName) if (s === undefined) servers.set(e.serverName, (s = {})) s.data = e.data }) rpcEventHandlers.set(Relay.Event.ServerRename, e => { servers.set(e.new, servers.get(e.serverName)) servers.delete(e.serverName) }) rpcEventHandlers.set(Relay.Event.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 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] for (const _ in 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 }, makeMark: line => { switch (line.rendition) { case Relay.Rendition.Indent: return m('span.mark', {}, '') case Relay.Rendition.Status: return m('span.mark', {}, '–') case Relay.Rendition.Error: return m('span.mark.error', {}, '⚠') case Relay.Rendition.Join: return m('span.mark.join', {}, '→') case Relay.Rendition.Part: return m('span.mark.part', {}, '←') case Relay.Rendition.Action: return m('span.mark.action', {}, '✶') } }, view: vnode => { let line = vnode.children[0] 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, [ Content.makeMark(line), line.items.flatMap(item => { switch (item.kind) { case Relay.Item.Text: return Content.linkify(item.text, { class: Array.from(classes.keys()).join(' '), style: Content.applyColor(fg, bg, inverse), }) case Relay.Item.Reset: classes.clear() fg = bg = -1 inverse = false break case Relay.Item.FgColor: fg = item.color break case Relay.Item.BgColor: bg = item.color break case Relay.Item.FlipInverse: inverse = !inverse break case Relay.Item.FlipBold: flip('b') break case Relay.Item.FlipItalic: flip('i') break case Relay.Item.FlipUnderline: flip('u') break case Relay.Item.FlipCrossedOut: flip('s') break case Relay.Item.FlipMonospace: flip('m') break } }), ]) }, } let Topic = { view: vnode => { let b = buffers.get(bufferCurrent) if (b !== undefined && b.topic !== undefined) return m(Content, {}, {items: b.topic}) }, } 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', {onscroll: event => { const dom = event.target bufferAutoscroll = dom.scrollTop + dom.clientHeight + 0.5 >= dom.scrollHeight }}, 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 Completions = { entries: [], reset: list => { Completions.entries = list || [] m.redraw() }, view: vnode => { if (!Completions.entries.length) return return m('.completions', {}, Completions.entries.map(option => m('.completion', {}, option))) }, } let BufferContainer = { view: vnode => { return m('.buffer-container', {}, [ m('.filler'), bufferLog !== undefined ? m(Log) : m(Buffer), m(Completions), ]) }, } let Toolbar = { view: vnode => { return m('.toolbar', {}, [ bufferLog === undefined && !bufferAutoscroll ? '⇩' : undefined, m('button', {onclick: event => bufferToggleLog()}, bufferLog === undefined ? 'Log' : 'Hide log'), ]) }, } let Status = { view: vnode => { let b = buffers.get(bufferCurrent) if (b === undefined) return m('.status', {}, 'Synchronizing...') let status = `${bufferCurrent}` if (b.hideUnimportant) status += `` return m('.status', {}, [status, m(Toolbar)]) }, } let Prompt = { view: vnode => { let b = buffers.get(bufferCurrent) if (b === undefined || b.server === undefined) return if (b.server.data.user !== undefined) { let user = b.server.data.user if (b.server.data.userMode) user += `(${b.server.data.userMode})` return m('.prompt', {}, `${user}`) } // This might certainly be done more systematically. let state = b.server.data.state for (const s in Relay.ServerState) if (Relay.ServerState[s] == state) { state = s break } return m('.prompt', {}, `(${state})`) }, } let Input = { counter: 0, stamp: textarea => { return [Input.counter, textarea.selectionStart, textarea.selectionEnd, textarea.value] }, complete: (b, 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 if (resp.completions.length > 0) { textarea.setRangeText(resp.completions[0], start, textarea.selectionEnd, 'end') } if (resp.completions.length == 1) { textarea.setRangeText(' ', textarea.selectionStart, textarea.selectionEnd, 'end') } else { beep() } if (resp.completions.length > 1) Completions.reset(resp.completions.slice(1)) }) return true }, submit: (b, 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. b.history.push(textarea.value) b.historyAt = b.history.length textarea.value = '' return true }, backward: textarea => { if (textarea.selectionStart !== textarea.selectionEnd) return false let point = textarea.selectionStart if (point < 1) return false while (point && /\s/.test(textarea.value.charAt(--point))) {} while (point-- && !/\s/.test(textarea.value.charAt(point))) {} point++ textarea.setSelectionRange(point, point) return true }, forward: textarea => { if (textarea.selectionStart !== textarea.selectionEnd) return false let point = textarea.selectionStart, len = textarea.value.length if (point + 1 > len) return false while (point < len && /\s/.test(textarea.value.charAt(point))) point++ while (point < len && !/\s/.test(textarea.value.charAt(point))) point++ textarea.setSelectionRange(point, point) return true }, modifyWord: (textarea, cb) => { let start = textarea.selectionStart let end = textarea.selectionEnd if (start === end) { let len = textarea.value.length while (start < len && /\s/.test(textarea.value.charAt(start))) start++; end = start while (end < len && !/\s/.test(textarea.value.charAt(end))) end++; } if (start === end) return false const text = textarea.value, modified = cb(text.substring(start, end)) textarea.value = text.slice(0, start) + modified + text.slice(end) end = start + modified.length textarea.setSelectionRange(end, end) return true }, downcase: textarea => { return Input.modifyWord(textarea, text => text.toLowerCase()) }, upcase: textarea => { return Input.modifyWord(textarea, text => text.toUpperCase()) }, capitalize: textarea => { return Input.modifyWord(textarea, text => { const cps = Array.from(text.toLowerCase()) return cps[0].toUpperCase() + cps.slice(1).join('') }) }, first: (b, textarea) => { if (b.historyAt <= 0) return false if (b.historyAt == b.history.length) b.input = textarea.value textarea.value = b.history[(b.historyAt = 0)] return true }, last: (b, textarea) => { if (b.historyAt >= b.history.length) return false b.historyAt = b.history.length textarea.value = b.input return true }, previous: (b, textarea) => { if (b.historyAt <= 0) return false if (b.historyAt == b.history.length) b.input = textarea.value textarea.value = b.history[--b.historyAt] return true }, next: (b, textarea) => { if (b.historyAt >= b.history.length) return false 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 b = buffers.get(bufferCurrent) if (b === undefined) return let textarea = event.currentTarget let handled = false let success = true if (hasShortcutModifiers(event)) { handled = true switch (event.key) { case 'b': success = Input.backward(textarea); break case 'f': success = Input.forward(textarea); break case 'l': success = Input.downcase(textarea); break case 'u': success = Input.upcase(textarea); break case 'c': success = Input.capitalize(textarea); break case '<': success = Input.first(b, textarea); break case '>': success = Input.last(b, textarea); break case 'p': success = Input.previous(b, textarea); break case 'n': success = Input.next(b, textarea); break default: handled = false } } else if (!event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { handled = true switch (event.keyCode) { case 9: success = Input.complete(b, textarea); break case 13: success = Input.submit(b, textarea); break default: handled = false } } if (!success) beep() if (handled) event.preventDefault() }, view: vnode => { return m('textarea#input', { rows: 1, onkeydown: Input.onKeyDown, oninput: event => Completions.reset(), // Sadly only supported in Firefox as of writing. onselectionchange: event => Completions.reset(), // The list of completions is scrollable without receiving focus. onblur: event => Completions.reset(), }) }, } let Main = { view: vnode => { let overlay = undefined if (connecting) overlay = m('.overlay', {}, "Connecting...") else if (rpc.ws === undefined) overlay = m('.overlay', {}, [ m('', {}, "Disconnected"), m('', {}, m('small', {}, "Reload page to reconnect.")), ]) return m('.xP', {}, [ overlay, m('.title', {}, [m('b', {}, `xP`), m(Topic)]), m('.middle', {}, [m(BufferList), m(BufferContainer)]), m(Status), m('.input', {}, [m(Prompt), 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 'H': if (bufferCurrent !== undefined) bufferToggleUnimportant(bufferCurrent) 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)