xK/xP/public/xP.js

1197 lines
30 KiB
JavaScript
Raw Permalink 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 - 2023, Přemysl Eric Janouch <p@janouch.name>
// 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 bufferPopExcessLines(b) {
// Let "new" messages be, if only because pulling the log file
// is much more problematic in the web browser than in xC.
// TODO: Make the limit configurable, or extract general.backlog_limit.
const old = b.lines.length - b.newMessages - b.newUnimportantMessages
b.lines.splice(0, old - 1000)
}
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 || e.leakToActive)
b.newUnimportantMessages++
else
b.newMessages++
}
// XXX: In its unkeyed diff algorithm, Mithril.js can only efficiently
// deal with common prefixes, i.e., indefinitely growing buffers.
// But we don't want to key all children of Buffer,
// so only trim buffers while they are, or once they become invisible.
if (e.bufferName != bufferCurrent)
bufferPopExcessLines(b)
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
b.modes = e.context.modes
})
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)
if (e.bufferName === bufferCurrent)
bufferCurrent = e.new
if (e.bufferName === bufferLast)
bufferLast = e.new
})
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)
bufferPopExcessLines(old)
}
// Initial sync: trim all buffers to our limit, just for consistency.
if (bufferCurrent === undefined) {
for (let b of buffers.values())
bufferPopExcessLines(b)
}
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.BufferInput, e => {
let b = buffers.get(e.bufferName)
if (b === undefined)
return
if (b.historyAt == b.history.length)
b.historyAt++
b.history.push(e.text)
})
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,.:]|\([^\[\](){}<>"'\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
}
// The role makes it selectable in VIM-like browser extensions.
return m('.item[role=tab]', {
onclick: event => bufferActivate(name),
onauxclick: event => {
if (event.button == 1)
rpc.send({
command: 'BufferInput',
bufferName: name,
text: '/buffer close',
})
},
class: classes.join(' '),
}, displayName)
})
updateIcon(rpc.ws === undefined ? null : highlighted)
return m('.list[role=tablist]', {}, 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][rel=noreferrer]',
{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 = {
onupdate: vnode => {
if (bufferAutoscroll)
vnode.dom.scrollTop = vnode.dom.scrollHeight
},
oncreate: vnode => {
Buffer.onupdate(vnode)
vnode.state.controller = new AbortController()
window.addEventListener('resize', event => Buffer.onupdate(vnode),
{signal: vnode.state.controller.signal})
Buffer.setDateChangeTimeout(vnode)
},
onremove: vnode => {
vnode.state.controller.abort()
clearTimeout(vnode.state.dateChangeTimeout)
},
setDateChangeTimeout: vnode => {
let midnight = new Date()
midnight.setHours(24, 0, 0, 0)
// Note that this doesn't handle time zone changes correctly.
vnode.state.dateChangeTimeout = setTimeout(() => {
m.redraw()
Buffer.setDateChangeTimeout(vnode)
}, midnight - new Date())
},
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 + 1 >= dom.scrollHeight
let b = buffers.get(bufferCurrent)
if (b !== undefined && b.highlighted && !bufferAutoscroll) {
b.highlighted = false
m.redraw()
}
}}, 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][rel=noreferrer]',
{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 = {
format: formatting => {
let textarea = document.getElementById('input')
if (textarea !== null)
Input.format(textarea, formatting)
},
view: vnode => {
let indicators = []
if (bufferLog === undefined && !bufferAutoscroll)
indicators.push(m('.indicator', {}, '⇩'))
if (Input.formatting)
indicators.push(m('.indicator', {}, '#'))
return m('.toolbar', {}, [
indicators,
m('button', {onclick: event => Toolbar.format('\u0002')},
m('b', {}, 'B')),
m('button', {onclick: event => Toolbar.format('\u001D')},
m('i', {}, 'I')),
m('button', {onclick: event => Toolbar.format('\u001F')},
m('u', {}, 'U')),
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.modes)
status += `(+${b.modes})`
if (b.hideUnimportant)
status += `<H>`
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.userModes)
user += `(${b.server.data.userModes})`
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
},
formatting: false,
format: (textarea, formatting) => {
const [start, end] = [textarea.selectionStart, textarea.selectionEnd]
if (start === end) {
textarea.setRangeText(formatting)
textarea.setSelectionRange(
start + formatting.length, end + formatting.length)
} else {
textarea.setRangeText(
formatting + textarea.value.substr(start, end) + formatting)
}
textarea.focus()
},
onKeyDown: event => {
// TODO: And perhaps on other actions, too.
rpc.send({command: 'Active'})
let b = buffers.get(bufferCurrent)
if (b === undefined || event.isComposing)
return
let textarea = event.currentTarget
let handled = false
let success = true
if (Input.formatting) {
Input.formatting = false
// Like process_formatting_escape() within xC.
handled = true
switch (event.key) {
case 'b': Input.format(textarea, '\u0002'); break
case 'c': Input.format(textarea, '\u0003'); break
case 'q':
case 'm': Input.format(textarea, '\u0011'); break
case 'v': Input.format(textarea, '\u0016'); break
case 'i':
case ']': Input.format(textarea, '\u001D'); break
case 's':
case 'x':
case '^': Input.format(textarea, '\u001E'); break
case 'u':
case '_': Input.format(textarea, '\u001F'); break
case 'r':
case 'o': Input.format(textarea, '\u000F'); break
default: success = false
}
} else 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
case 'm': success = Input.formatting = true; break
default: handled = false
}
} else if (!event.altKey && !event.ctrlKey && !event.metaKey &&
!event.shiftKey) {
handled = true
switch (event.key) {
case 'PageUp':
Array.from(document.getElementsByClassName('buffer'))
.forEach(b => b.scrollBy(0, -b.clientHeight))
break
case 'PageDown':
Array.from(document.getElementsByClassName('buffer'))
.forEach(b => b.scrollBy(0, +b.clientHeight))
break
case 'Tab':
success = Input.complete(b, textarea);
break
case 'Enter':
success = Input.submit(b, textarea);
break
default:
handled = false
}
}
if (!success)
beep()
if (handled)
event.preventDefault()
},
onStateChange: event => {
Completions.reset()
Input.formatting = false
},
view: vnode => {
return m('textarea#input', {
rows: 1,
onkeydown: Input.onKeyDown,
oninput: Input.onStateChange,
// Sadly only supported in Firefox as of writing.
onselectionchange: Input.onStateChange,
// The list of completions is scrollable without receiving focus.
onblur: Input.onStateChange,
})
},
}
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) {
// https://www.w3.org/TR/uievents-key/#keys-modifier
// https://bugzilla.mozilla.org/show_bug.cgi?id=1232918
if (["Alt", "AltGraph", "CapsLock", "Control", "Fn", "FnLock",
"Meta", "NumLock", "ScrollLock", "Shift", "Symbol", "SymbolLock",
"Hyper", "Super", "OS"].indexOf(event.key) != -1)
return
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)