xK/xP/public/xP.js

1197 lines
30 KiB
JavaScript
Raw Permalink Normal View History

// 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
2022-09-06 20:17:23 +02:00
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 --------------------------------------------------------------
2022-09-11 15:54:39 +02:00
function utf8Encode(s) { return new TextEncoder().encode(s) }
function utf8Decode(s) { return new TextDecoder().decode(s) }
function hasShortcutModifiers(event) {
2022-09-12 16:43:13 +02:00
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()
2022-09-10 19:33:39 +02:00
let bufferLast = undefined
let bufferCurrent = undefined
2022-09-06 23:37:06 +02:00
let bufferLog = undefined
let bufferAutoscroll = true
2022-09-06 23:37:06 +02:00
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)
}
2022-09-10 19:33:39 +02:00
function bufferActivate(name) {
rpc.send({command: 'BufferActivate', bufferName: name})
}
function bufferToggleUnimportant(name) {
rpc.send({command: 'BufferToggleUnimportant', bufferName: name})
}
function bufferToggleLog() {
2022-09-21 07:32:18 +02:00
// 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()
})
}
2022-09-06 20:17:23 +02:00
let connecting = true
rpc.connect().then(result => {
buffers.clear()
2022-09-10 19:33:39 +02:00
bufferLast = undefined
2022-09-06 20:17:23 +02:00
bufferCurrent = undefined
2022-09-06 23:37:06 +02:00
bufferLog = undefined
bufferAutoscroll = true
2022-09-06 23:37:06 +02:00
servers.clear()
rpc.send({command: 'Hello', version: Relay.version})
2022-09-06 20:17:23 +02:00
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()
}
})
2022-09-21 12:13:30 +02:00
rpcEventHandlers.set(Relay.Event.Ping, e => {
rpc.send({command: 'PingResponse', eventSeq: e.eventSeq})
2022-09-21 12:13:30 +02:00
})
// ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2022-09-21 12:13:30 +02:00
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)
2022-09-21 12:13:30 +02:00
b.newUnimportantMessages++
else
b.newMessages++
}
2024-01-06 23:27:22 +01:00
// 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)
2022-09-21 12:13:30 +02:00
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)
2022-09-21 12:13:30 +02:00
b.topic = e.context.topic
2022-09-21 16:32:08 +02:00
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)
2022-09-10 19:33:39 +02:00
if (e.bufferName === bufferLast)
bufferLast = undefined
})
rpcEventHandlers.set(Relay.Event.BufferActivate, e => {
2022-09-06 23:17:47 +02:00
let old = buffers.get(bufferCurrent)
2024-01-06 23:27:22 +01:00
if (old !== undefined) {
bufferResetStats(old)
2024-01-06 23:27:22 +01:00
bufferPopExcessLines(old)
}
// Initial sync: trim all buffers to our limit, just for consistency.
if (bufferCurrent === undefined) {
for (let b of buffers.values())
bufferPopExcessLines(b)
}
2022-09-10 19:33:39 +02:00
bufferLast = bufferCurrent
let b = buffers.get(e.bufferName)
bufferCurrent = e.bufferName
2022-09-06 23:37:06 +02:00
bufferLog = undefined
bufferAutoscroll = true
if (b !== undefined && document.visibilityState !== 'hidden')
b.highlighted = false
2022-09-06 23:17:47 +02:00
let textarea = document.getElementById('input')
if (textarea === null)
return
textarea.focus()
2022-09-11 01:01:16 +02:00
if (old !== undefined) {
2022-09-06 23:17:47 +02:00
old.input = textarea.value
2022-09-11 01:01:16 +02:00
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
2022-09-11 01:01:16 +02:00
}
2022-09-06 23:17:47 +02:00
2022-09-11 01:01:16 +02:00
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 ---------------------------------------------------------------------
2022-09-10 17:18:08 +02:00
let linkRE = [
/https?:\/\//,
/([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/,
/([^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))/,
2022-09-10 17:18:08 +02:00
].map(r => r.source).join('')
let BufferList = {
view: vnode => {
let highlighted = false
2022-09-06 23:37:06 +02:00
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]', {
2022-09-10 19:33:39 +02:00
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 = {
2022-09-07 14:07:14 +02:00
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]
2022-09-21 06:23:41 +02:00
for (const _ in style)
2022-09-07 14:07:14 +02:00
return style
},
linkify: (text, attrs) => {
2022-09-10 17:18:08 +02:00
let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
2022-09-07 14:07:14 +02:00
while ((match = re.exec(text)) !== null) {
if (end < match.index)
a.push(m('span', attrs, text.substring(end, match.index)))
2024-03-04 16:12:43 +01:00
a.push(m('a[target=_blank][rel=noreferrer]',
{href: match[0], ...attrs}, match[0]))
2022-09-07 14:07:14 +02:00
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
}
}),
])
},
}
2022-09-21 12:13:30 +02:00
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})
2023-01-24 08:02:08 +01:00
Buffer.setDateChangeTimeout(vnode)
},
onremove: vnode => {
vnode.state.controller.abort()
2023-01-24 08:02:08 +01:00
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
2022-09-11 21:30:51 +02:00
let squashing = false
let markBefore = b.lines.length
- b.newMessages - b.newUnimportantMessages
b.lines.forEach((line, i) => {
if (i == markBefore)
lines.push(m('.unread'))
2022-09-11 21:30:51 +02:00
if (!line.isUnimportant || !b.hideUnimportant) {
squashing = false
} else if (squashing) {
return
2022-09-11 21:30:51 +02:00
} else {
squashing = true
}
let date = new Date(line.when)
let dateMark = date.toLocaleDateString()
if (dateMark !== lastDateMark) {
lines.push(m('.date', {}, dateMark))
lastDateMark = dateMark
}
2022-09-11 21:30:51 +02:00
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))
})
2022-09-07 19:23:17 +02:00
let dateMark = new Date().toLocaleDateString()
if (dateMark !== lastDateMark && lastDateMark !== undefined)
2022-09-07 19:23:17 +02:00
lines.push(m('.date', {}, dateMark))
2022-09-21 07:32:18 +02:00
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()
}
2022-09-21 07:32:18 +02:00
}}, lines)
2022-09-06 23:37:06 +02:00
},
}
2022-09-07 14:07:14 +02:00
let Log = {
oncreate: vnode => {
vnode.dom.scrollTop = vnode.dom.scrollHeight
vnode.dom.focus()
},
2022-09-10 17:18:08 +02:00
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))
2024-03-04 16:12:43 +01:00
a.push(m('a[target=_blank][rel=noreferrer]',
{href: match[0]}, match[0]))
2022-09-10 17:18:08 +02:00
end = re.lastIndex
}
if (end < text.length)
a.push(text.substring(end))
return a
},
2022-09-07 14:07:14 +02:00
view: vnode => {
2022-09-10 17:18:08 +02:00
return m(".log", {}, Log.linkify(bufferLog))
2022-09-06 23:37:06 +02:00
},
2022-09-07 14:07:14 +02:00
}
2022-09-06 23:37:06 +02:00
2022-09-18 05:53:44 +02:00
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)))
},
}
2022-09-07 14:07:14 +02:00
let BufferContainer = {
2022-09-06 23:37:06 +02:00
view: vnode => {
2022-09-07 14:07:14 +02:00
return m('.buffer-container', {}, [
m('.filler'),
bufferLog !== undefined ? m(Log) : m(Buffer),
2022-09-18 05:53:44 +02:00
m(Completions),
])
},
}
2022-09-21 07:32:18 +02:00
let Toolbar = {
format: formatting => {
let textarea = document.getElementById('input')
if (textarea !== null)
Input.format(textarea, formatting)
},
2022-09-21 07:32:18 +02:00
view: vnode => {
let indicators = []
if (bufferLog === undefined && !bufferAutoscroll)
indicators.push(m('.indicator', {}, '⇩'))
if (Input.formatting)
indicators.push(m('.indicator', {}, '#'))
2022-09-21 07:32:18 +02:00
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')),
2022-09-21 07:32:18 +02:00
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}`
2022-09-21 16:32:08 +02:00
if (b.modes)
status += `(+${b.modes})`
if (b.hideUnimportant)
status += `<H>`
2022-09-21 07:32:18 +02:00
return m('.status', {}, [status, m(Toolbar)])
2022-09-19 03:16:34 +02:00
},
}
2022-09-19 03:16:34 +02:00
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
2022-09-21 16:32:08 +02:00
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
2022-09-19 03:16:34 +02:00
for (const s in Relay.ServerState)
if (Relay.ServerState[s] == state) {
2022-09-19 03:16:34 +02:00
state = s
break
}
return m('.prompt', {}, `(${state})`)
},
}
2022-09-07 14:07:14 +02:00
let Input = {
counter: 0,
stamp: textarea => {
return [Input.counter,
textarea.selectionStart, textarea.selectionEnd, textarea.value]
},
2022-09-18 00:38:31 +02:00
complete: (b, textarea) => {
if (textarea.selectionStart !== textarea.selectionEnd)
2022-09-07 14:07:14 +02:00
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,
2022-09-11 15:54:39 +02:00
position: utf8Encode(
textarea.value.slice(0, textarea.selectionEnd)).length,
2022-09-06 23:37:06 +02:00
}).then(resp => {
if (!Input.stamp(textarea).every((v, k) => v === state[k]))
return
2022-09-11 15:54:39 +02:00
let preceding = utf8Encode(textarea.value).slice(0, resp.start)
let start = utf8Decode(preceding).length
2022-09-18 05:53:44 +02:00
if (resp.completions.length > 0) {
2022-09-06 23:37:06 +02:00
textarea.setRangeText(resp.completions[0],
2022-09-11 15:54:39 +02:00
start, textarea.selectionEnd, 'end')
}
2022-09-18 05:53:44 +02:00
if (resp.completions.length == 1) {
textarea.setRangeText(' ',
textarea.selectionStart, textarea.selectionEnd, 'end')
2022-09-18 05:53:44 +02:00
} else {
beep()
}
if (resp.completions.length > 1)
Completions.reset(resp.completions.slice(1))
})
2022-09-07 14:07:14 +02:00
return true
},
2022-09-18 00:38:31 +02:00
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 = ''
2022-09-07 14:07:14 +02:00
return true
},
2022-09-18 01:46:00 +02:00
backward: textarea => {
2022-09-18 01:09:41 +02:00
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
},
2022-09-18 01:46:00 +02:00
forward: textarea => {
2022-09-18 01:09:41 +02:00
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
},
2022-09-18 01:46:00 +02:00
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('')
})
},
2022-09-18 00:38:31 +02:00
first: (b, textarea) => {
if (b.historyAt <= 0)
return false
2022-09-18 00:38:31 +02:00
if (b.historyAt == b.history.length)
b.input = textarea.value
textarea.value = b.history[(b.historyAt = 0)]
return true
},
2022-09-18 00:38:31 +02:00
last: (b, textarea) => {
if (b.historyAt >= b.history.length)
return false
2022-09-18 00:38:31 +02:00
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()
},
2022-09-07 14:07:14 +02:00
onKeyDown: event => {
// TODO: And perhaps on other actions, too.
rpc.send({command: 'Active'})
2022-09-18 00:38:31 +02:00
let b = buffers.get(bufferCurrent)
if (b === undefined || event.isComposing)
2022-09-18 00:38:31 +02:00
return
2022-09-07 14:07:14 +02:00
let textarea = event.currentTarget
let handled = false
2022-09-18 00:38:31 +02:00
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)) {
2022-09-18 00:38:31 +02:00
handled = true
switch (event.key) {
2022-09-18 01:46:00 +02:00
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
2022-09-18 00:38:31 +02:00
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
2022-09-18 00:38:31 +02:00
default: handled = false
}
} else if (!event.altKey && !event.ctrlKey && !event.metaKey &&
!event.shiftKey) {
2022-09-18 00:38:31 +02:00
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
}
2022-09-07 14:07:14 +02:00
}
2022-09-18 00:38:31 +02:00
if (!success)
beep()
2022-09-07 14:07:14 +02:00
if (handled)
event.preventDefault()
},
onStateChange: event => {
Completions.reset()
Input.formatting = false
},
view: vnode => {
2022-09-18 05:53:44 +02:00
return m('textarea#input', {
rows: 1,
onkeydown: Input.onKeyDown,
oninput: Input.onStateChange,
2022-09-18 05:53:44 +02:00
// Sadly only supported in Firefox as of writing.
onselectionchange: Input.onStateChange,
2022-09-18 05:53:44 +02:00
// The list of completions is scrollable without receiving focus.
onblur: Input.onStateChange,
2022-09-18 05:53:44 +02:00
})
},
}
let Main = {
view: vnode => {
let overlay = undefined
2022-09-06 20:17:23 +02:00
if (connecting)
overlay = m('.overlay', {}, "Connecting...")
2022-09-06 20:17:23 +02:00
else if (rpc.ws === undefined)
overlay = m('.overlay', {}, [
m('', {}, "Disconnected"),
m('', {}, m('small', {}, "Reload page to reconnect.")),
])
2022-09-06 20:17:23 +02:00
return m('.xP', {}, [
overlay,
2022-09-21 12:13:30 +02:00
m('.title', {}, [m('b', {}, `xP`), m(Topic)]),
2022-09-07 14:07:14 +02:00
m('.middle', {}, [m(BufferList), m(BufferContainer)]),
m(Status),
2022-09-19 03:16:34 +02:00
m('.input', {}, [m(Prompt), m(Input)]),
])
},
}
2022-09-06 21:27:14 +02:00
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()
}
})
2022-09-12 16:43:13 +02:00
// 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 => {
2022-09-12 16:43:13 +02:00
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
2022-09-12 16:43:13 +02:00
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) {
2022-09-10 19:33:39 +02:00
bufferActivate(name)
break
}
break
case '!':
for (const name of names.slice(1))
if (buffers.get(name).highlighted) {
2022-09-10 19:33:39 +02:00
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:
2022-09-10 19:33:39 +02:00
return
}
2022-09-10 19:33:39 +02:00
event.preventDefault()
2022-09-12 16:43:13 +02:00
}, true)