summaryrefslogtreecommitdiff
path: root/xP/public
diff options
context:
space:
mode:
Diffstat (limited to 'xP/public')
-rw-r--r--xP/public/ircfmt.woff2bin0 -> 1240 bytes
-rw-r--r--xP/public/xP.css257
-rw-r--r--xP/public/xP.js1108
3 files changed, 1365 insertions, 0 deletions
diff --git a/xP/public/ircfmt.woff2 b/xP/public/ircfmt.woff2
new file mode 100644
index 0000000..d4262bc
--- /dev/null
+++ b/xP/public/ircfmt.woff2
Binary files differ
diff --git a/xP/public/xP.css b/xP/public/xP.css
new file mode 100644
index 0000000..e8b28f2
--- /dev/null
+++ b/xP/public/xP.css
@@ -0,0 +1,257 @@
+@font-face {
+ src: url('ircfmt.woff2') format('woff2');
+ font-family: 'IRC Formatting';
+ font-weight: normal;
+ font-style: normal;
+}
+body {
+ margin: 0;
+ padding: 0;
+ /* Firefox only renders C0 within the textarea, why? */
+ font-family: 'IRC Formatting', sans-serif;
+ font-size: clamp(0.5rem, 2vw, 1rem);
+}
+.xP {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100vh;
+ /* https://caniuse.com/viewport-unit-variants */
+ height: 100dvh;
+}
+
+.overlay {
+ padding: .6em .9em;
+ background: #eee;
+ border: 1px outset #eee;
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ z-index: 1;
+}
+.title, .status {
+ padding: .05em .3em;
+ background: #eee;
+
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ column-gap: .3em;
+
+ position: relative;
+ border-top: 3px solid #ccc;
+ border-bottom: 2px solid #888;
+}
+.title {
+ /* To approximate right-aligned space-between. */
+ flex-direction: row-reverse;
+}
+.title:before, .status:before {
+ content: " ";
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 2px;
+ top: -2px;
+ background: #fff;
+}
+.title:after, .status:after {
+ content: " ";
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 1px;
+ bottom: -1px;
+ background: #ccc;
+}
+
+.toolbar {
+ display: flex;
+ align-items: baseline;
+ margin-right: -.3em;
+}
+.indicator {
+ margin: 0 .3em;
+}
+button {
+ font: inherit;
+ background: transparent;
+ border: 1px solid transparent;
+ padding: 0 .3em;
+}
+button:focus {
+ border: 1px dotted #000;
+}
+button:hover {
+ border-left: 1px solid #fff;
+ border-top: 1px solid #fff;
+ border-right: 1px solid #888;
+ border-bottom: 1px solid #888;
+}
+button:hover:active {
+ border-left: 1px solid #888;
+ border-top: 1px solid #888;
+ border-right: 1px solid #fff;
+ border-bottom: 1px solid #fff;
+}
+
+.middle {
+ flex: auto;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+}
+
+.list {
+ overflow-y: auto;
+ border-right: 2px solid #ccc;
+ min-width: 10em;
+ flex-shrink: 0;
+}
+.item {
+ padding: .05em .3em;
+ cursor: default;
+}
+.item.highlighted {
+ color: #ff5f00;
+}
+.item.activity {
+ font-weight: bold;
+}
+.item.current {
+ font-style: italic;
+ background: #eee;
+}
+
+/* Only Firefox currently supports align-content: safe end, thus this. */
+.buffer-container {
+ flex: auto;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+}
+.filler {
+ flex: auto;
+}
+.buffer {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ overflow-y: auto;
+}
+.log {
+ font-family: monospace;
+ overflow-y: auto;
+}
+.log, .content, .completions {
+ /* Note: https://bugs.chromium.org/p/chromium/issues/detail?id=1261435 */
+ white-space: break-spaces;
+ overflow-wrap: break-word;
+}
+.log, .buffer .content {
+ padding: .1em .3em;
+}
+
+.leaked {
+ opacity: 50%;
+}
+.date {
+ padding: .3em;
+ grid-column: span 2;
+ font-weight: bold;
+}
+.unread {
+ grid-column: span 2;
+ border-top: 1px solid #ff5f00;
+}
+.time {
+ padding: .1em .3em;
+ background: #f8f8f8;
+ color: #bbb;
+ border-right: 1px solid #ccc;
+}
+.time.hidden:after {
+ border-top: .2em dotted #ccc;
+ display: block;
+ width: 50%;
+ margin: 0 auto;
+ content: "";
+}
+.mark {
+ padding-right: .3em;
+ text-align: center;
+ display: inline-block;
+ min-width: 2em;
+}
+.mark.error {
+ color: red;
+}
+.mark.join {
+ color: green;
+}
+.mark.part {
+ color: red;
+}
+.mark.action {
+ color: darkred;
+}
+.content .b {
+ font-weight: bold;
+}
+.content .i {
+ font-style: italic;
+}
+.content .u {
+ text-decoration: underline;
+}
+.content .s {
+ text-decoration: line-through;
+}
+.content .m {
+ font-family: monospace;
+}
+
+.completions {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: #fff;
+ padding: .05em .3em;
+ border-top: 1px solid #888;
+
+ max-height: 50%;
+ display: flex;
+ flex-flow: column wrap;
+ column-gap: .6em;
+ overflow-x: auto;
+}
+.input {
+ flex-shrink: 0;
+ border: 2px inset #eee;
+ overflow: hidden;
+ resize: vertical;
+ display: flex;
+}
+.input:focus-within {
+ border-color: #ff5f00;
+}
+.prompt {
+ padding: .05em .3em;
+ border-right: 1px solid #ccc;
+ background: #f8f8f8;
+ font-weight: bold;
+}
+textarea {
+ font: inherit;
+ padding: .05em .3em;
+ margin: 0;
+ border: 0;
+ flex-grow: 1;
+ resize: none;
+}
+textarea:focus {
+ outline: none;
+}
diff --git a/xP/public/xP.js b/xP/public/xP.js
new file mode 100644
index 0000000..3266063
--- /dev/null
+++ b/xP/public/xP.js
@@ -0,0 +1,1108 @@
+// Copyright (c) 2022, 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 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
+ 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)
+})
+
+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,.:]|\([^\[\](){}<>"'\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 + 1 >= 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 = {
+ 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)
+ 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.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()
+ },
+
+ 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) {
+ 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)