diff options
Diffstat (limited to 'xP/public')
-rw-r--r-- | xP/public/ircfmt.woff2 | bin | 0 -> 1240 bytes | |||
-rw-r--r-- | xP/public/xP.css | 257 | ||||
-rw-r--r-- | xP/public/xP.js | 1108 |
3 files changed, 1365 insertions, 0 deletions
diff --git a/xP/public/ircfmt.woff2 b/xP/public/ircfmt.woff2 Binary files differnew file mode 100644 index 0000000..d4262bc --- /dev/null +++ b/xP/public/ircfmt.woff2 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) |