xK/xP/public/xP.js

883 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
// 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(e.event, {detail: 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(''))
}
}
// ---- 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 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('Ping', event => {
rpc.send({command: 'PingResponse', eventSeq: event.detail.eventSeq})
})
// ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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
b.kind = e.context.kind
b.server = servers.get(e.context.serverName)
})
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
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)
}
})
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.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
}
})
rpc.addEventListener('BufferClear', event => {
let e = event.detail, b = buffers.get(e.bufferName)
if (b !== undefined)
b.lines.length = 0
})
// ~~~ Server events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
rpc.addEventListener('ServerUpdate', event => {
let e = event.detail, s = servers.get(e.serverName)
if (s === undefined)
servers.set(e.serverName, (s = {}))
s.state = e.state
})
rpc.addEventListener('ServerRename', event => {
let e = event.detail
servers.set(e.new, servers.get(e.serverName))
servers.delete(e.serverName)
})
rpc.addEventListener('ServerRemove', event => {
let e = event.detail
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 += `<H>`
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)