feat: replies on every article, in the board's own chrome

A message base is a thing people answer, and until now this one could only be
read. The site already calls its articles messages in areas, so replies are the
feature the metaphor was asking for rather than a comment box bolted onto it.

Talks to the comment server's API directly instead of loading its widget. The
widget is a React app in an iframe and would arrive with its own fonts, spacing
and avatars; an iframe cannot inherit the page's font in any case. 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. All five pre elements in
the section measure 80 in a real browser.

Two things this surfaced, both verified by driving a browser rather than curl:

The command handler took every letter key as a command, which was harmless when
there was nothing on the board to type into. With a message box, typing `i`
navigated to the index mid-sentence. It now ignores keys aimed at an input or
textarea.

bbs.js already says the bracket chrome is a promise rather than decoration, and
[P]ost was drawn without wiring P to anything. P now focuses the message box.

Handles are checked here as well as at the server, since remark42 wants three
or more characters and only letters, numbers, underscores and spaces; failing
in the page is better than a refusal with no explanation.

Without JS the section is simply absent, which is honest, and the capsule never
sees it since Gemini has no way to render it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-10-06 18:10:23 +03:00
parent 0fc11795f7
commit 4f77f16191
3 changed files with 189 additions and 0 deletions

View file

@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
function fill(n) { return n > 0 ? new Array(n + 1).join('─') : ''; }
function span(cls, text) { return '<span class="' + cls + '">' + esc(text) + '</span>'; }
/* `── 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 '<pre class="gb-rule">' + span('box', '──[') + span('count', num) +
span('box', ']── ') + span('area', who) + span('box', fill(gap)) +
span('date', ' ' + when + ' ') + span('box', '──') + '</pre>';
}
function header(count) {
var title = ' REPLIES ';
var tail = count === 1 ? '1 message ' : count + ' messages ';
var left = '┌─' + title;
var right = tail + '─┐';
return '<pre class="box">' + esc(left) + fill(W - left.length - right.length) + esc(right) + '</pre>';
}
function render(list) {
var html = header(list.length);
list.forEach(function (c, i) {
html += rule(i + 1, c.user.name, stamp(c.time));
html += '<div class="gb-body">' + c.text + '</div>';
});
if (!list.length) {
html += '<div class="gb-body">No replies yet. Be the first.</div>';
}
box.innerHTML = html + form();
wire();
}
function form() {
return '<pre class="rule">' + fill(W) + '</pre>' +
'<form class="gb-form" id="replyform">' +
'<div class="gb-row"><label for="rh">Handle</label>' +
'<input id="rh" name="handle" maxlength="24" required placeholder="anonymous"></div>' +
'<div class="gb-row"><label for="rt">Message</label>' +
'<textarea id="rt" name="text" rows="4" required></textarea></div>' +
'<div class="gb-row"><label></label>' +
'<button type="submit">' + span('key', '[') + '<b>P</b>' + span('key', ']') + 'ost</button>' +
' <span class="hint" id="rs"></span></div></form>';
}
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) +
'<div class="gb-body">The message base is unreachable.</div>'; });
}
/* 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();
})();

View file

@ -20,4 +20,13 @@
<pre class="tagline">--- {{ SITENAME }} BBS
<span class="origin">* Origin:</span> randogoth.com <span class="origin">(1:1/1)</span></pre>
{#- 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 %}
<div id="replies" data-host="{{ COMMENTS_HOST }}" data-site="{{ COMMENTS_SITE }}"
data-url="{{ SITEURL }}/{{ article.url }}"></div>
{%- endif %}
{% endblock %}

View file

@ -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'