diff --git a/randogoth.com/bbs/static/bbs.js b/randogoth.com/bbs/static/bbs.js index cc9c8c7..f6a8bb0 100644 --- a/randogoth.com/bbs/static/bbs.js +++ b/randogoth.com/bbs/static/bbs.js @@ -165,6 +165,11 @@ var BBS = {}; addEventListener('keydown', function (e) { if (e.metaKey || e.ctrlKey || e.altKey) return; + // Until the reply form existed there was nothing on the board to type + // into, so every letter was a command. Now `i` in a message box would + // navigate to the index mid-sentence. + var t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; var k = e.key; clearTimeout(timer); @@ -226,3 +231,172 @@ var BBS = {}; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })(); + +/* Replies. + * + * A message base is a thing people answer, and until now this one could only be + * read. The comments come from a remark42 instance which serves three sites + * from one database; everything below talks to its API directly rather than + * loading its widget, because the widget is a React app in an iframe and would + * arrive with its own fonts, spacing and avatars. None of that can be made to + * look like 80 columns of CP437, and an iframe cannot inherit the page's font + * in any case. + * + * The rules are drawn the way the guestbook draws them, padded to exactly 80 + * cells, so a reply is indistinguishable from any other row on the board. + * + * Auth is anonymous and the token comes back in a header rather than a cookie, + * which is what lets this work from another origin at all: a cross-site cookie + * would be blocked outright by Safari and Firefox. + */ +(function () { + var W = 80; + + function init() { + var box = document.getElementById('replies'); + if (!box) return; + var host = box.dataset.host, site = box.dataset.site, url = box.dataset.url; + var token = null; + + function esc(s) { + return String(s).replace(/[&<>"]/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; + }); + } + function fill(n) { return n > 0 ? new Array(n + 1).join('─') : ''; } + function span(cls, text) { return '' + esc(text) + ''; } + + /* `── 06 oct 26 · 14:09 ──`, lower case, as the listings elsewhere do it. */ + function stamp(iso) { + var d = new Date(iso); + if (isNaN(d)) return ''; + var mon = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'][d.getMonth()]; + function p(n) { return (n < 10 ? '0' : '') + n; } + return p(d.getDate()) + ' ' + mon + ' ' + String(d.getFullYear()).slice(2) + + ' · ' + p(d.getHours()) + ':' + p(d.getMinutes()); + } + + /* One rule, padded so the visible width is exactly W. */ + function rule(n, who, when) { + var num = (' ' + n).slice(-3) + ' '; + var left = '──[' + num + ']── ' + who; + var right = ' ' + when + ' ──'; + var gap = W - left.length - right.length; + if (gap < 1) { // a very long handle: trim it, never overflow + who = who.slice(0, Math.max(1, who.length + gap - 1)) + '…'; + left = '──[' + num + ']── ' + who; + gap = W - left.length - right.length; + } + return '
' + span('box', '──[') + span('count', num) +
+             span('box', ']── ') + span('area', who) + span('box', fill(gap)) +
+             span('date', ' ' + when + ' ') + span('box', '──') + '
'; + } + + function header(count) { + var title = ' REPLIES '; + var tail = count === 1 ? '1 message ' : count + ' messages '; + var left = '┌─' + title; + var right = tail + '─┐'; + return '
' + esc(left) + fill(W - left.length - right.length) + esc(right) + '
'; + } + + function render(list) { + var html = header(list.length); + list.forEach(function (c, i) { + html += rule(i + 1, c.user.name, stamp(c.time)); + html += '
' + c.text + '
'; + }); + if (!list.length) { + html += '
No replies yet. Be the first.
'; + } + box.innerHTML = html + form(); + wire(); + } + + function form() { + return '
' + fill(W) + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
'; + } + + function say(msg) { var s = document.getElementById('rs'); if (s) s.textContent = msg; } + + function load() { + return fetch(host + '/api/v1/find?site=' + encodeURIComponent(site) + + '&url=' + encodeURIComponent(url) + '&format=tree') + .then(function (r) { return r.json(); }) + .then(function (d) { + render((d.comments || []).map(function (n) { return n.comment; })); + }) + .catch(function () { box.innerHTML = header(0) + + '
The message base is unreachable.
'; }); + } + + /* Anonymous login hands the JWT back in a header; we keep it ourselves so + no cookie is involved and nothing is blocked as third-party. */ + function login(name) { + return fetch(host + '/auth/anonymous/login?user=' + encodeURIComponent(name) + + '&aud=' + encodeURIComponent(site) + '&site=' + encodeURIComponent(site)) + .then(function (r) { + token = r.headers.get('X-JWT'); + if (!token) throw new Error('no token'); + }); + } + + function post(name, text) { + // The site goes on the query string as well as in the body: a middleware + // reads it from there, and without it an authenticated request is + // refused outright. + return fetch(host + '/api/v1/comment?site=' + encodeURIComponent(site), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-JWT': token }, + body: JSON.stringify({ text: text, locator: { site: site, url: url } }) + }).then(function (r) { + if (!r.ok) throw new Error(r.status); + }); + } + + function wire() { + var f = document.getElementById('replyform'); + if (!f) return; + f.addEventListener('submit', function (e) { + e.preventDefault(); + var name = (f.handle.value || 'anonymous').trim(); + var text = f.text.value.trim(); + if (!text) return; + // remark42 wants at least three characters, letters, numbers, + // underscores and spaces only. Fail here rather than at the server. + if (name.length < 3 || /[^A-Za-z0-9_ ]/.test(name)) { + say('handle: 3+ characters, letters numbers _ and spaces'); return; + } + say('posting…'); + login(name).then(function () { return post(name, text); }) + .then(function () { say(''); return load(); }) + .catch(function () { say('could not post, try again'); }); + }); + } + + // P puts the cursor in the message box. The bracket chrome is a promise + // everywhere else on this board, so it has to be one here too. + addEventListener('keydown', function (e) { + if (e.metaKey || e.ctrlKey || e.altKey) return; + var t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; + if (e.key === 'p' || e.key === 'P') { + var ta = document.getElementById('rt'); + if (ta) { ta.focus(); e.preventDefault(); } + } + }); + + load(); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); diff --git a/randogoth.com/bbs/templates/article.html b/randogoth.com/bbs/templates/article.html index a96a24f..7324189 100644 --- a/randogoth.com/bbs/templates/article.html +++ b/randogoth.com/bbs/templates/article.html @@ -20,4 +20,13 @@
--- {{ SITENAME }} BBS
  * Origin: randogoth.com (1:1/1)
+ +{#- Replies. A message base is a thing people answer, which is the one feature + the metaphor was missing. Filled by bbs.js from the comment server; without + JS the section simply is not there, which is honest, and the capsule never + sees it at all since Gemini has no way to render it. -#} +{%- if COMMENTS_HOST %} +
+{%- endif %} {% endblock %} diff --git a/randogoth.com/pelicanconf.py b/randogoth.com/pelicanconf.py index b7f1455..a8c8bd7 100644 --- a/randogoth.com/pelicanconf.py +++ b/randogoth.com/pelicanconf.py @@ -98,3 +98,9 @@ MARKDOWN = { DELETE_OUTPUT_DIRECTORY = True CACHE_CONTENT = False + +# The comment server. One instance serves three sites, keyed on COMMENTS_SITE, +# so this is the id rather than the hostname. Unset it and the replies section +# disappears from every article, which is the off switch. +COMMENTS_HOST = 'https://comments.randogoth.com' +COMMENTS_SITE = 'randogoth'