fedipod-bb 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +3 -2
- package/site/bb.js +235 -351
- package/site/draw.mjs +370 -0
- package/site/index.html +2 -2
- package/site/pod.mjs +17 -0
- package/site/read.mjs +67 -18
- package/src/forum-agent.mjs +62 -4
- package/src/gateway.mjs +19 -0
- package/src/render.mjs +135 -0
package/site/draw.mjs
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// draw.mjs — the forum's public views as the page and the server that draws
|
|
2
|
+
// it both show them: what the front page, a category and a topic read, and
|
|
3
|
+
// the markup each is drawn as. Plain strings from plain data, with no
|
|
4
|
+
// document to hand, so a server draws exactly what a browser does. Runs in a
|
|
5
|
+
// browser and in Node alike.
|
|
6
|
+
|
|
7
|
+
import { authorLabel, cacheKey, cleanHtml, inTurn } from './read.mjs';
|
|
8
|
+
|
|
9
|
+
export const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
10
|
+
export const when = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10); };
|
|
11
|
+
|
|
12
|
+
// Text with its markup taken off, for a title or a description.
|
|
13
|
+
export function plainText(html, max = 0) {
|
|
14
|
+
const ents = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' };
|
|
15
|
+
const s = String(html || '').replace(/<[^>]*>/gu, ' ').replace(/&(amp|lt|gt|quot|#39|nbsp);/gu, (_, e) => ents[e])
|
|
16
|
+
.replace(/\s+/gu, ' ').trim();
|
|
17
|
+
if (!max || s.length <= max) return s;
|
|
18
|
+
const cut = s.slice(0, max);
|
|
19
|
+
return `${cut.slice(0, cut.lastIndexOf(' ') > max / 2 ? cut.lastIndexOf(' ') : max)}…`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Who is reading, as far as the drawing cares. Nobody signed in is what a
|
|
23
|
+
// stranger sees, and what the server draws.
|
|
24
|
+
export const NOBODY = Object.freeze({
|
|
25
|
+
account: false, pod: false, moderates: false, isNew: () => false,
|
|
26
|
+
votedOn: () => 'none', isSaved: () => false, isJoined: () => false, mine: () => false,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Where each public view is: under the forum's own path at its own host, and
|
|
30
|
+
// after `#` anywhere else.
|
|
31
|
+
export function addresses(root = null) {
|
|
32
|
+
const part = (s) => encodeURIComponent(String(s));
|
|
33
|
+
return {
|
|
34
|
+
front: () => root || '#/',
|
|
35
|
+
cat: (slug) => (root ? `${root}c/${part(slug)}` : `#/c/${slug}`),
|
|
36
|
+
topic: (slug, tid, post = null) => (root
|
|
37
|
+
? `${root}t/${part(slug)}/${part(tid)}${post ? `#p-${post}` : ''}`
|
|
38
|
+
: `#/t/${slug}/${tid}${post ? `/${post}` : ''}`),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- the pieces a view is drawn from -------------------------------------
|
|
43
|
+
//
|
|
44
|
+
// `d` is what every piece needs besides its data: `viewer` (NOBODY or the
|
|
45
|
+
// reader's own), `addr` (addresses()), `handleOf(cat)` (the category's handle
|
|
46
|
+
// as this page names it) and `parse` (the cleaner's parser, where there is no
|
|
47
|
+
// browser to parse with).
|
|
48
|
+
|
|
49
|
+
// A post names the category it is for, and every reader of this page is
|
|
50
|
+
// already in it: the address is how it got here, not something to read.
|
|
51
|
+
export function body(p, cat, d) {
|
|
52
|
+
const drop = cat ? (href, words) => href === cat.id
|
|
53
|
+
|| words.replace(/^@/u, '') === String(cat.slug || '') || words === d.handleOf(cat) : null;
|
|
54
|
+
return d.parse ? cleanHtml(p?.content, d.parse, { drop }) : cleanHtml(p?.content, undefined, { drop });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The author as they are known: their name when the forum kept a card for
|
|
58
|
+
// them, and always the handle, which is the thing that is theirs everywhere.
|
|
59
|
+
export function byline(p, author) {
|
|
60
|
+
if (!author) return esc(p.author ? authorLabel(p.author) : '');
|
|
61
|
+
const handle = esc(author.handle);
|
|
62
|
+
const name = author.name && author.name !== author.handle ? esc(author.name) : null;
|
|
63
|
+
const inner = name ? `${name} <span class="dim">${handle}</span>` : handle;
|
|
64
|
+
// Their posts in this forum, not their page elsewhere: that is what a
|
|
65
|
+
// reader here is asking for when they click a name.
|
|
66
|
+
return `<a href="#/who/${encodeURIComponent(author.id)}">${inner}</a>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Where this post can be READ away from here. A pod answers its post's own
|
|
70
|
+
// address with JSON, so that address is no use to a person and is not shown;
|
|
71
|
+
// a server that publishes a page for it says so, and the link names its host.
|
|
72
|
+
export function elsewhere(p) {
|
|
73
|
+
if (!p?.page) return '';
|
|
74
|
+
let host = '';
|
|
75
|
+
try { host = new URL(p.page).host; } catch { return ''; }
|
|
76
|
+
return ` <a href="${esc(p.page)}" class="dim" rel="noopener">on ${esc(host)}</a>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function topicActs(topicId, d) {
|
|
80
|
+
if (!d.viewer.moderates) return '';
|
|
81
|
+
return `<div class="acts mod" role="group" aria-label="Moderator actions for this topic" data-topic="${esc(topicId)}">
|
|
82
|
+
<button data-act="rename">Rename topic</button>
|
|
83
|
+
<button data-act="unlock">Reopen topic</button>
|
|
84
|
+
<button data-act="pin">Pin topic</button><button data-act="unpin">Unpin topic</button>
|
|
85
|
+
<button data-act="sitepin">Pin site-wide</button><button data-act="siteunpin">Unpin site-wide</button>
|
|
86
|
+
<button class="grave" data-act="lock">Close topic</button><button class="grave" data-act="droptopic">Delete topic</button>
|
|
87
|
+
</div>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Voting, both ways, as a forum has always had it: up, down, and the one you
|
|
91
|
+
// cast again to take it back. A reader with no account sees the counts.
|
|
92
|
+
export function votes(p, d) {
|
|
93
|
+
const up = p.likes || 0;
|
|
94
|
+
const down = p.dislikes || 0;
|
|
95
|
+
// Signed out, the buttons are still buttons: pressing one asks who you are
|
|
96
|
+
// and then casts the vote. Showing the counts as dead text hid the fact that
|
|
97
|
+
// voting was possible at all.
|
|
98
|
+
const mineWay = d.viewer.account ? d.viewer.votedOn(p.id) : 'none';
|
|
99
|
+
const one = (way, mark, n, label) => `<button data-act="vote-${mineWay === way ? 'none' : way}"
|
|
100
|
+
data-was="${mineWay}"${mineWay === way ? ' aria-pressed="true" class="voted"' : ''}
|
|
101
|
+
aria-label="${mineWay === way ? `Take back your vote ${label}` : `Vote ${label} this post`}">${mark} ${n}</button>`;
|
|
102
|
+
return one('up', '▲', up, 'for') + one('down', '▼', down, 'against');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// One post. A closed topic offers no Reply.
|
|
106
|
+
export function card(p, { author = null, cat = null, extra = '', waiting = false, anchor = null, closed = false } = {}, d) {
|
|
107
|
+
const v = d.viewer;
|
|
108
|
+
const at = p.published ? when(p.published) : '';
|
|
109
|
+
// What a screen reader hears before the words, and what each of this post's
|
|
110
|
+
// own buttons is called — a thread of them is not twenty identical "Reply"s.
|
|
111
|
+
const whose = esc(author ? (author.name || author.handle) : (p.author ? authorLabel(p.author) : 'someone'));
|
|
112
|
+
const named = `${waiting ? 'Waiting: p' : 'P'}ost by ${whose}${at ? `, ${esc(at)}` : ''}`;
|
|
113
|
+
return `<article class="post${p.gone ? ' gone' : ''}${extra}" aria-label="${named}"${anchor ? ` id="p-${esc(anchor)}"` : ''}>
|
|
114
|
+
<div class="who"><b>${waiting ? '<span class="dim">waiting for the forum · </span>' : ''}${byline(p, author)}</b>
|
|
115
|
+
<span class="when">${esc(at)}${elsewhere(p)}</span></div>
|
|
116
|
+
<div class="body">${p.gone ? 'This post was removed.' : (body(p, cat, d) || '<span class="dim">(not readable here)</span>')}</div>
|
|
117
|
+
${waiting && p.resend && p.id ? `<div class="acts" data-post="${esc(p.id)}">
|
|
118
|
+
<button data-act="resend" aria-label="Send your post to the forum again">Send again</button>
|
|
119
|
+
</div>` : ''}
|
|
120
|
+
${p.gone || waiting || !p.id ? '' : `<div class="acts" data-post="${esc(p.id)}">
|
|
121
|
+
${closed ? '' : `<button data-act="reply" aria-label="Reply to ${whose}">Reply</button>`}
|
|
122
|
+
${votes(p, d)}
|
|
123
|
+
<button data-act="share" aria-label="Copy a link to the post by ${whose}">Share</button>
|
|
124
|
+
${v.account ? `<button data-act="${v.isSaved(p.id) ? 'unsave' : 'save'}" aria-label="${v.isSaved(p.id) ? 'Remove this post from your bookmarks' : 'Bookmark this post'}">${v.isSaved(p.id) ? 'Bookmarked' : 'Bookmark'}</button>` : ''}
|
|
125
|
+
${v.mine(p) || !v.account ? '' : `<button data-act="report" aria-label="Report the post by ${whose}">Report</button>`}
|
|
126
|
+
${v.mine(p) ? '<button data-act="edit" aria-label="Edit your own post">Edit</button><button data-act="delete" aria-label="Delete your own post">Delete</button>' : ''}
|
|
127
|
+
${!v.mine(p) && v.moderates ? `<button data-act="remove" aria-label="Remove the post by ${whose}">Remove</button>` : ''}
|
|
128
|
+
</div>`}
|
|
129
|
+
</article>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A thread in the shape it was written: each reply under the post it answers,
|
|
133
|
+
// and anything whose parent is not here at the top level. Depth is capped so
|
|
134
|
+
// a long argument does not walk off the side of the page.
|
|
135
|
+
const MAX_DEPTH = 6;
|
|
136
|
+
export function threaded(posts, authors, cat, anchors, d, { closed = false } = {}) {
|
|
137
|
+
const byId = new Map(posts.map(p => [p.id, p]));
|
|
138
|
+
const kids = new Map();
|
|
139
|
+
const roots = [];
|
|
140
|
+
for (const p of posts) {
|
|
141
|
+
const parent = p.inReplyTo && byId.has(p.inReplyTo) && p.inReplyTo !== p.id ? p.inReplyTo : null;
|
|
142
|
+
if (!parent) { roots.push(p); continue; }
|
|
143
|
+
if (!kids.has(parent)) kids.set(parent, []);
|
|
144
|
+
kids.get(parent).push(p);
|
|
145
|
+
}
|
|
146
|
+
const out = [];
|
|
147
|
+
const walk = (p, depth) => {
|
|
148
|
+
const html = card(p, { author: (p.author && authors[p.author]) || null, cat, anchor: anchors[p.id] || null, closed }, d);
|
|
149
|
+
out.push(depth ? `<div class="nest" style="--depth:${Math.min(depth, MAX_DEPTH)}">${html}</div>` : html);
|
|
150
|
+
for (const k of kids.get(p.id) || []) walk(k, depth + 1);
|
|
151
|
+
};
|
|
152
|
+
for (const r of roots) walk(r, 0);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// One stop is no trail: the front page needs no line saying it is the front page.
|
|
157
|
+
export function crumbs(parts) {
|
|
158
|
+
if (parts.length < 2) return { html: '', hidden: true };
|
|
159
|
+
return { hidden: false,
|
|
160
|
+
html: parts.map(([label, href], i) => (href && i < parts.length - 1 ? `<a href="${esc(href)}">${esc(label)}</a>` : esc(label))).join(' › ') };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The line naming who moderates, each name leading to their posts here.
|
|
164
|
+
export const modsWho = (names) => (names.length
|
|
165
|
+
? `<span class="lead">moderators:</span> ${names.map(n => `<a href="#/who/${encodeURIComponent(n.id)}">${esc(n.handle)}</a>`).join(', ')}`
|
|
166
|
+
: '');
|
|
167
|
+
|
|
168
|
+
// What to call each moderator: the card the forum kept of them, then the
|
|
169
|
+
// actor's own word for it, and a name read off the address only when neither
|
|
170
|
+
// answers. `authorOf(catBase, id)` is the caller's, so a page asks once.
|
|
171
|
+
export function modNames(read, cats, ids, authorOf, at = 1) {
|
|
172
|
+
const cat = cats[0];
|
|
173
|
+
return inTurn(ids, at, async (id) => {
|
|
174
|
+
const card = cat ? await authorOf(cat.base, id).catch(() => null) : null;
|
|
175
|
+
return { id, handle: card?.handle || (await read.actorHandle(id).catch(() => null)) || authorLabel(id) };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- the front page and a category ----------------------------------------
|
|
180
|
+
|
|
181
|
+
// What the front page reads: the forum's newest posts, and the pinned topics
|
|
182
|
+
// and the moderators of the forum and of every category. Kept whole, for
|
|
183
|
+
// every category, because which subset is on screen is the reader's business.
|
|
184
|
+
export async function readIndex(read, base, cats, want, at = 1) {
|
|
185
|
+
const latest = await read.latest(base, { limit: want });
|
|
186
|
+
const site = await read.featured(base);
|
|
187
|
+
const pinned = {};
|
|
188
|
+
const mods = {};
|
|
189
|
+
await inTurn(cats, at, async (c) => {
|
|
190
|
+
pinned[c.id] = await read.featured(c.base);
|
|
191
|
+
mods[c.id] = await read.moderators(c.base);
|
|
192
|
+
});
|
|
193
|
+
return { at: Date.now(), limit: want, latest: [...latest], more: latest.more || 0, site, pinned, mods };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Who moderates what is on screen: one category's own, or, showing the whole
|
|
197
|
+
// forum, whoever moderates any of them.
|
|
198
|
+
export function moderatorsOf(idx, here) {
|
|
199
|
+
if (here) return idx.mods[here.id] || [];
|
|
200
|
+
return [...new Set(Object.values(idx.mods).flat())];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The rows the index shows: one per topic, the newest post in it standing
|
|
204
|
+
// for it, pinned topics first and then the column the reader chose.
|
|
205
|
+
export function frontRows({ cats, idx, filter = 'all', finding = '', order = 'date', down = true, shown = 30 }) {
|
|
206
|
+
const here = cats.find(c => c.slug === filter) || null;
|
|
207
|
+
const looking = finding.trim().toLowerCase();
|
|
208
|
+
const mine = idx.latest.filter(p => (filter === 'all' || (cats.find(c => c.id === p.category)?.slug === filter))
|
|
209
|
+
// Searching what the page has: a topic's name, an author's handle, and
|
|
210
|
+
// the words of the post itself.
|
|
211
|
+
&& (!looking || [p.topicName, p.author, p.content].some(v => String(v || '').toLowerCase().includes(looking))));
|
|
212
|
+
// A pin belongs to a category; the forum has a featured collection of its
|
|
213
|
+
// own for a pin that holds everywhere.
|
|
214
|
+
const pins = { cat: new Set(), site: new Set(idx.site) };
|
|
215
|
+
for (const c of cats) {
|
|
216
|
+
if (filter !== 'all' && c.slug !== filter) continue;
|
|
217
|
+
for (const id of idx.pinned[c.id] || []) pins.cat.add(id);
|
|
218
|
+
}
|
|
219
|
+
const byTopic = new Map();
|
|
220
|
+
for (const p of mine) {
|
|
221
|
+
if (!p.topic) continue;
|
|
222
|
+
const held = byTopic.get(p.topic);
|
|
223
|
+
if (!held || String(p.published || '') > String(held.published || '')) byTopic.set(p.topic, p);
|
|
224
|
+
}
|
|
225
|
+
const rank = (p) => (pins.site.has(p.topic) ? 2 : pins.cat.has(p.topic) ? 1 : 0);
|
|
226
|
+
// Posts, not replies: a topic with nobody answering yet still HAS the post
|
|
227
|
+
// that started it. The count the forum keeps is of replies, so the opening
|
|
228
|
+
// post is the one added here.
|
|
229
|
+
const by = (p) => (order === 'replies' ? (p.topicReplies || 0) + 1 : String(p.published || ''));
|
|
230
|
+
const topics = [...byTopic.values()].sort((a, b) => rank(b) - rank(a)
|
|
231
|
+
|| (down ? (by(a) > by(b) ? -1 : by(a) < by(b) ? 1 : 0) : (by(a) > by(b) ? 1 : by(a) < by(b) ? -1 : 0)));
|
|
232
|
+
const page = topics.slice(0, shown);
|
|
233
|
+
return { here, pins, topics, page, more: Math.max(0, topics.length - page.length) + (idx.more || 0) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Who wrote each row, and the digest its link carries.
|
|
237
|
+
export function rowFacts(page, cats, authorOf, digestOf = cacheKey) {
|
|
238
|
+
return Promise.all(page.map(async (p) => {
|
|
239
|
+
const cat = cats.find(c => c.id === p.category) || null;
|
|
240
|
+
return { cat, who: cat && p.author ? await authorOf(cat.base, p.author) : null, digest: await digestOf(p.id) };
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The categories line, and the search and new-topic controls at the end of
|
|
245
|
+
// the moderators line.
|
|
246
|
+
export function frontChips({ cats, filter, here }, d) {
|
|
247
|
+
const v = d.viewer;
|
|
248
|
+
const chip = (slug, label) => `<a class="chip${filter === slug ? ' on' : ''}"${filter === slug ? ' aria-current="page"' : ''} href="${esc(slug === 'all' ? d.addr.front() : d.addr.cat(slug))}">${esc(label)}</a>`;
|
|
249
|
+
return `<p class="hint chips">Categories: ${chip('all', 'All')}${cats.map(c => chip(c.slug || '', c.name)).join('')}
|
|
250
|
+
${v.account ? '<a class="chip" href="#/replies">Replies to you</a><a class="chip" href="#/bookmarks">Bookmarked</a>' : ''}
|
|
251
|
+
${v.moderates ? '<a class="chip" href="#/queue">Queue</a><a class="chip" href="#/settings">Settings</a>' : ''}
|
|
252
|
+
<span class="rowend">
|
|
253
|
+
${here && v.account && v.pod ? `<button class="new" data-act="${v.isJoined(here.id) ? 'leave' : 'join'}" data-cat="${esc(here.id)}">${v.isJoined(here.id) ? 'Leave' : 'Join'}</button>` : ''}
|
|
254
|
+
</span></p>`;
|
|
255
|
+
}
|
|
256
|
+
export function frontActs({ cats, filter, finding = '' }) {
|
|
257
|
+
// Starting a topic from the front page: in the category being shown, or the
|
|
258
|
+
// first one when the whole forum is.
|
|
259
|
+
const into = cats.find(c => c.slug === filter) || cats[0] || null;
|
|
260
|
+
return `<label class="vh" for="find">Search this forum</label>
|
|
261
|
+
<input type="text" id="find" placeholder="Search" value="${esc(finding)}" autocomplete="off"${finding ? '' : ' hidden'}>
|
|
262
|
+
<button data-act="search"${finding ? ' hidden' : ''}>Search</button>
|
|
263
|
+
${into ? `<button data-act="newtopic" data-slug="${esc(into.slug || '')}">New topic</button>` : ''}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The front page or a category, drawn: `rows` from frontRows, `facts` from
|
|
267
|
+
// rowFacts, `membersOnly` when a category that lists nothing will not answer
|
|
268
|
+
// this reader. Returns the parts of the page and what is said aloud.
|
|
269
|
+
export function drawFront({ forum, cats, filter, finding = '', order = 'date', down = true, rows, facts = [], membersOnly = false, front }, d) {
|
|
270
|
+
const { here, pins, page, more } = rows;
|
|
271
|
+
const head = frontChips({ cats, filter, here }, d);
|
|
272
|
+
const out = {
|
|
273
|
+
crumbs: crumbs(here ? [['Home', d.addr.front()], [here.name, d.addr.cat(here.slug)]] : [['Home', d.addr.front()]]),
|
|
274
|
+
acts: frontActs({ cats, filter, finding }),
|
|
275
|
+
title: here ? here.name : (forum.name || 'FediPod-BB'),
|
|
276
|
+
description: plainText(here ? here.summary : forum.summary, 200),
|
|
277
|
+
fresh: 0,
|
|
278
|
+
};
|
|
279
|
+
if (!rows.topics.length) {
|
|
280
|
+
// A members-only category that will not answer this reader looks exactly
|
|
281
|
+
// like an empty one, which is why the caller asked it.
|
|
282
|
+
if (here && membersOnly) {
|
|
283
|
+
return { ...out, say: 'This category is for its members',
|
|
284
|
+
main: head + (d.viewer.pod
|
|
285
|
+
? `<p class="empty row">This category is for its members.
|
|
286
|
+
<button class="new" data-act="ask-join" data-cat="${esc(here.id)}">Request membership</button></p>`
|
|
287
|
+
: `<p class="empty row">This category is for its members, and membership needs a FediPod account.
|
|
288
|
+
<a class="big" href="${esc(front)}/new-account">Get a FediPod account</a></p>`) };
|
|
289
|
+
}
|
|
290
|
+
// The one thing to do on an empty page is the one thing offered.
|
|
291
|
+
return { ...out, say: 'Nothing posted here yet',
|
|
292
|
+
main: head + '<p class="empty">Nothing posted here yet. Use the button at the upper right to create a topic.</p>' };
|
|
293
|
+
}
|
|
294
|
+
const tr = [];
|
|
295
|
+
for (let i = 0; i < page.length; i++) {
|
|
296
|
+
const p = page[i];
|
|
297
|
+
const { cat, who, digest } = facts[i] || {};
|
|
298
|
+
const tid = p.topic ? p.topic.split('/').pop() : null;
|
|
299
|
+
const href = cat && tid ? d.addr.topic(cat.slug, tid, digest) : null;
|
|
300
|
+
const name = esc(p.topicName || 'Topic');
|
|
301
|
+
// New to THIS reader: posted since they last had the topic open.
|
|
302
|
+
const isNew = d.viewer.isNew(p.topic, p.published);
|
|
303
|
+
if (isNew) out.fresh += 1;
|
|
304
|
+
const by = who ? who.handle : (p.author ? authorLabel(p.author) : '');
|
|
305
|
+
tr.push(`<tr>
|
|
306
|
+
<td>${pins.site.has(p.topic) ? '<span class="pin" title="Pinned across the forum" role="img" aria-label="pinned across the forum">📌</span> ' : pins.cat.has(p.topic) ? '<span class="pin" title="Pinned in this category" role="img" aria-label="pinned in this category">📍</span> ' : ''}${href ? `<a href="${esc(href)}">${name}</a>` : name}${isNew
|
|
307
|
+
? ' <span class="badge">New<span class="vh"> since you last opened this topic</span></span>' : ''}</td>
|
|
308
|
+
<td>${cat ? esc(cat.name) : ''}</td>
|
|
309
|
+
<td>${by ? (p.author
|
|
310
|
+
? `<a class="who" href="#/who/${encodeURIComponent(p.author)}" title="${esc(by)}">${esc(by)}</a>`
|
|
311
|
+
: `<span class="who" title="${esc(by)}">${esc(by)}</span>`) : ''}</td>
|
|
312
|
+
<td>${esc(when(p.published))}</td>
|
|
313
|
+
<td>${Number.isFinite(p.topicReplies) ? p.topicReplies + 1 : ''}</td>
|
|
314
|
+
</tr>`);
|
|
315
|
+
}
|
|
316
|
+
// A table, so a reader can run their eye down any one of the four things
|
|
317
|
+
// an entry says.
|
|
318
|
+
out.main = head + `<div class="scroll" role="region" aria-label="Topics, most recently posted in first" tabindex="0"><table class="index">
|
|
319
|
+
<thead><tr><th scope="col">Topic</th><th scope="col">Category</th><th scope="col">Latest by</th>
|
|
320
|
+
<th scope="col" aria-sort="${order === 'date' ? (down ? 'descending' : 'ascending') : 'none'}">
|
|
321
|
+
<button class="sort" data-act="order" data-order="date">Date ${order === 'date' ? (down ? '▼' : '▲') : '<span class="dim">▽</span>'}</button></th>
|
|
322
|
+
<th scope="col" aria-sort="${order === 'replies' ? (down ? 'descending' : 'ascending') : 'none'}">
|
|
323
|
+
<button class="sort" data-act="order" data-order="replies">Posts ${order === 'replies' ? (down ? '▼' : '▲') : '<span class="dim">▽</span>'}</button></th></tr></thead>
|
|
324
|
+
<tbody>${tr.join('')}</tbody></table>
|
|
325
|
+
${more ? `<p class="row"><button data-act="more">Show ${Math.min(more, 30)} more of ${more}</button></p>` : ''}</div>`;
|
|
326
|
+
out.say = `${tr.length} topic${tr.length === 1 ? '' : 's'}${out.fresh ? `, ${out.fresh} with something new` : ''}`;
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- a topic ---------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
// What a topic reads: its head and every post's readable copy, each author's
|
|
333
|
+
// card, the anchor each post is linked by, and who moderates its category.
|
|
334
|
+
export async function readTopic(read, cat, tid, at = 1) {
|
|
335
|
+
const topicId = cat.base + 'ap/topic/' + tid;
|
|
336
|
+
const moderators = await read.moderators(cat.base);
|
|
337
|
+
const t = await read.topic(topicId);
|
|
338
|
+
if (!t) return { topicId, t: null, moderators };
|
|
339
|
+
const copies = await inTurn(t.posts, at, (id) => read.post(cat.base, id));
|
|
340
|
+
const posts = t.posts.map((id, i) => ({ id, ...(copies[i] || {}) }));
|
|
341
|
+
const who = [...new Set(posts.map(p => p.author).filter(Boolean))];
|
|
342
|
+
const cards = await inTurn(who, at, (a) => read.author(cat.base, a));
|
|
343
|
+
const authors = Object.fromEntries(who.map((a, i) => [a, cards[i]]));
|
|
344
|
+
const keys = await Promise.all(posts.map(p => cacheKey(p.id)));
|
|
345
|
+
const anchors = Object.fromEntries(posts.map((p, i) => [p.id, keys[i]]));
|
|
346
|
+
return { topicId, t, posts, authors, anchors, moderators };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A topic, drawn. `pending` are this reader's own posts not yet placed, as
|
|
350
|
+
// posts. A closed topic takes nothing more, so the page does not offer it.
|
|
351
|
+
export function drawTopic({ cat, slug, topicId, t, posts = [], authors = {}, anchors = {}, pending = [] }, d) {
|
|
352
|
+
if (!t) {
|
|
353
|
+
return { crumbs: crumbs([['Home', d.addr.front()], [cat.name, d.addr.cat(slug)], ['topic', null]]),
|
|
354
|
+
main: '<p class="err">No such topic.</p>', say: 'No such topic', title: cat.name, description: '' };
|
|
355
|
+
}
|
|
356
|
+
const closed = !!t.closed;
|
|
357
|
+
return {
|
|
358
|
+
crumbs: crumbs([['Home', d.addr.front()], [cat.name, d.addr.cat(slug)], [t.name, null]]),
|
|
359
|
+
title: t.name || cat.name,
|
|
360
|
+
description: plainText(body(posts.find(p => !p.gone) || {}, cat, d), 200),
|
|
361
|
+
say: `${t.name}, ${posts.length} post${posts.length === 1 ? '' : 's'}`,
|
|
362
|
+
main: `<div class="topline"><h1>${esc(t.name)}</h1>
|
|
363
|
+
${closed ? '' : `<button class="primary" data-act="newpost" data-topic="${esc(topicId)}">New post</button>`}</div>
|
|
364
|
+
${closed ? '<p class="hint">This topic is closed. No more replies are being taken.</p>' : ''}
|
|
365
|
+
${topicActs(topicId, d)}
|
|
366
|
+
|
|
367
|
+
${threaded(posts, authors, cat, anchors, d, { closed }).join('')}
|
|
368
|
+
${pending.map(w => card(w, { cat, extra: ' waiting', waiting: true }, d)).join('')}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
package/site/index.html
CHANGED
|
@@ -82,8 +82,8 @@
|
|
|
82
82
|
/* Every other row on a ground of its own, so a long line is easy to follow
|
|
83
83
|
across to its date. */
|
|
84
84
|
table.index tbody tr:nth-child(even) { background: var(--stripe); }
|
|
85
|
-
/* A pinned topic is marked with an emoji: a
|
|
86
|
-
pin within one category. The label is there for anyone not reading by eye. */
|
|
85
|
+
/* A pinned topic is marked with an emoji: a pushpin across the whole forum,
|
|
86
|
+
a round pin within one category. The label is there for anyone not reading by eye. */
|
|
87
87
|
.pin { font-size: 1.05rem; line-height: 1; }
|
|
88
88
|
|
|
89
89
|
/* The topic takes the room; the rest are as wide as their words and no wider. */
|
package/site/pod.mjs
CHANGED
|
@@ -156,6 +156,23 @@ export async function signInWithWebId(webId, redirectUri) {
|
|
|
156
156
|
// forum's inbox. A post has no title of its own; when it opens a topic, the
|
|
157
157
|
// activity that opens it carries the TOPIC's name, which is a different
|
|
158
158
|
// thing and belongs to the topic.
|
|
159
|
+
// A post the forum never took in, handed to it again as it was the first
|
|
160
|
+
// time: the same post, read back from the author's own pod, in the same
|
|
161
|
+
// Create. The forum adds a post to its topic once and carries it once,
|
|
162
|
+
// however often it arrives.
|
|
163
|
+
export async function resend({ actor, podHome, id, inbox, session = null, fetch: f = globalThis.fetch.bind(globalThis) }) {
|
|
164
|
+
const s = session || await getSession();
|
|
165
|
+
if (!s) throw new Error('sign in to your pod first');
|
|
166
|
+
const r = await s.fetch(podUrlOf(actor, podHome, id), { headers: { accept: 'application/activity+json' } });
|
|
167
|
+
if (!r.ok) throw new Error(`your pod could not give the post back (HTTP ${r.status})`);
|
|
168
|
+
const note = await r.json();
|
|
169
|
+
const create = { '@context': AS, id: `${id}#create`, type: 'Create', actor, published: note.published,
|
|
170
|
+
object: note, to: note.to || [], cc: note.cc || [] };
|
|
171
|
+
const sent = await f(inbox, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, body: JSON.stringify(create) });
|
|
172
|
+
if (!sent.ok) throw new Error(`the forum did not take it (HTTP ${sent.status})`);
|
|
173
|
+
return { id };
|
|
174
|
+
}
|
|
175
|
+
|
|
159
176
|
export async function post({ actor, podHome, category, categoryBase = null, categoryHandle, isPrivate = false,
|
|
160
177
|
inbox, topic = '', title = '', text, inReplyTo = null, context = null }) {
|
|
161
178
|
const s = await getSession();
|
package/site/read.mjs
CHANGED
|
@@ -10,6 +10,12 @@ const MAX_PAGES = 50;
|
|
|
10
10
|
|
|
11
11
|
export const categoryBase = (actorId) => String(actorId).replace(/ap\/actor$/u, '');
|
|
12
12
|
|
|
13
|
+
// A post this page just sent, not yet in what the forum lists: its server
|
|
14
|
+
// delivers it to the forum seconds after the page sent it.
|
|
15
|
+
export function sentNotListed(sent, latest, now = Date.now()) {
|
|
16
|
+
return !!sent?.id && now < sent.until && !(latest || []).some(p => p.id === sent.id);
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
// A post's text, one copy: a server that sends it once per language hands
|
|
14
20
|
// over a list, read as its first.
|
|
15
21
|
export function textOf(v) {
|
|
@@ -30,14 +36,24 @@ const escText = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '
|
|
|
30
36
|
const linkable = (href) => { try { return ['http:', 'https:', 'mailto:'].includes(new URL(href).protocol); } catch { return false; } };
|
|
31
37
|
const inert = (html) => new DOMParser().parseFromString(html, 'text/html').body;
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
// The words under a node, as a reader sees them.
|
|
40
|
+
const wordsOf = (n) => (n.nodeType === 3 ? String(n.nodeValue ?? '')
|
|
41
|
+
: n.nodeType === 1 ? [...n.childNodes].map(wordsOf).join('') : '');
|
|
42
|
+
|
|
43
|
+
// `drop(href, words)` names links to leave out; a paragraph a dropped link
|
|
44
|
+
// leaves with nothing in it goes too.
|
|
45
|
+
function rebuild(node, drop = null, left = { dropped: false }) {
|
|
34
46
|
let out = '';
|
|
35
47
|
for (const n of node.childNodes) {
|
|
36
48
|
if (n.nodeType === 3) { out += escText(n.nodeValue); continue; }
|
|
37
49
|
if (n.nodeType !== 1) continue;
|
|
38
50
|
const tag = n.nodeName.toLowerCase();
|
|
39
51
|
if (DROP.has(tag)) continue;
|
|
40
|
-
if (!KEEP.has(tag)) { out += rebuild(n); continue; }
|
|
52
|
+
if (!KEEP.has(tag)) { out += rebuild(n, drop, left); continue; }
|
|
53
|
+
if (tag === 'a' && drop?.([...n.attributes].find(a => a.name.toLowerCase() === 'href')?.value ?? null, wordsOf(n).trim())) {
|
|
54
|
+
left.dropped = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
41
57
|
let attrs = '';
|
|
42
58
|
for (const a of n.attributes) {
|
|
43
59
|
const name = a.name.toLowerCase();
|
|
@@ -45,14 +61,21 @@ function rebuild(node) {
|
|
|
45
61
|
attrs += ` ${name}="${escText(a.value)}"`;
|
|
46
62
|
}
|
|
47
63
|
if (tag === 'a') attrs += ' rel="nofollow noopener noreferrer"';
|
|
48
|
-
|
|
64
|
+
if (tag === 'p' && drop) {
|
|
65
|
+
const mine = { dropped: false };
|
|
66
|
+
const inner = rebuild(n, drop, mine);
|
|
67
|
+
if (mine.dropped) left.dropped = true;
|
|
68
|
+
if (!(mine.dropped && !inner.replace(/<[^>]*>/gu, '').trim() && !/<a\b/u.test(inner))) out += `<p${attrs}>${inner}</p>`;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out += tag === 'br' ? '<br>' : `<${tag}${attrs}>${rebuild(n, drop, left)}</${tag}>`;
|
|
49
72
|
}
|
|
50
73
|
return out;
|
|
51
74
|
}
|
|
52
75
|
|
|
53
|
-
export function cleanHtml(html, parse = inert) {
|
|
76
|
+
export function cleanHtml(html, parse = inert, { drop = null } = {}) {
|
|
54
77
|
const text = textOf(html);
|
|
55
|
-
return text ? rebuild(parse(text)) : '';
|
|
78
|
+
return text ? rebuild(parse(text), drop) : '';
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
// Where a forum is read from: through a Gateway at `<origin>/u/<handle>/`, or
|
|
@@ -75,7 +98,28 @@ export function placeOf({ origin, pathname = '/', search = '' }) {
|
|
|
75
98
|
const fromPath = own ? decodeURIComponent(pathname.split('/').filter(Boolean)[0] || '') : '';
|
|
76
99
|
const handle = params.get('forum') || fromPath || null;
|
|
77
100
|
const pod = params.get('pod');
|
|
78
|
-
|
|
101
|
+
// At its own host a forum's views have addresses under its own path.
|
|
102
|
+
const root = own && fromPath && !params.get('forum') && !pod ? `/${encodeURIComponent(fromPath)}/` : null;
|
|
103
|
+
return { front, handle, pod, own, root, base: pod || handle ? forumBase({ origin: front, handle, pod }) : null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Which view an address at the forum's own host names: the front page, a
|
|
107
|
+
// category, or a topic. Anything else under the forum's path is its front page.
|
|
108
|
+
export function viewOfPath(pathname, root) {
|
|
109
|
+
if (!root || !pathname.startsWith(root)) return { kind: 'front' };
|
|
110
|
+
const [kind, a, b] = pathname.slice(root.length).split('/').map(s => { try { return decodeURIComponent(s); } catch { return s; } });
|
|
111
|
+
if (kind === 'c' && a) return { kind: 'cat', slug: a };
|
|
112
|
+
if (kind === 't' && a && b) return { kind: 'topic', slug: a, tid: b };
|
|
113
|
+
return { kind: 'front' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A few at a time: `fn` over `items`, in order, never more than `n` waiting.
|
|
117
|
+
export async function inTurn(items, n, fn) {
|
|
118
|
+
const out = new Array(items.length);
|
|
119
|
+
let next = 0;
|
|
120
|
+
const one = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i], i); } };
|
|
121
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(n, items.length)) }, one));
|
|
122
|
+
return out;
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
// The name a cached copy is filed under (the same digest the host uses).
|
|
@@ -94,7 +138,9 @@ export function authorLabel(actorId) {
|
|
|
94
138
|
} catch { return String(actorId); }
|
|
95
139
|
}
|
|
96
140
|
|
|
97
|
-
|
|
141
|
+
// `at`: how many documents are asked for at once. The page asks one at a time;
|
|
142
|
+
// the server that draws a page asks a few at a time to finish in its time.
|
|
143
|
+
export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session = null, at = 1 } = {}) {
|
|
98
144
|
// A topic's name and how many posts are in it, read once however many of
|
|
99
145
|
// its posts a feed carries.
|
|
100
146
|
const topicInfo = async (topicId, seen) => {
|
|
@@ -144,12 +190,13 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
|
|
|
144
190
|
const actor = await get(base + 'ap/actor');
|
|
145
191
|
if (!actor) return null;
|
|
146
192
|
const list = await get(base + 'ap/categories');
|
|
147
|
-
const
|
|
148
|
-
for (const id of (list?.orderedItems || [])) {
|
|
193
|
+
const read = await inTurn(list?.orderedItems || [], at, async (id) => {
|
|
149
194
|
const a = await get(id);
|
|
150
|
-
|
|
195
|
+
return a ? { id, a, followers: await get(categoryBase(a.id || id) + 'ap/followers') } : null;
|
|
196
|
+
});
|
|
197
|
+
const categories = [];
|
|
198
|
+
for (const { id, a, followers } of read.filter(Boolean)) {
|
|
151
199
|
const cbase = categoryBase(a.id || id);
|
|
152
|
-
const followers = await get(cbase + 'ap/followers');
|
|
153
200
|
categories.push({
|
|
154
201
|
id: a.id || id, base: cbase, slug: a.preferredUsername || null,
|
|
155
202
|
name: a.name || a.preferredUsername || id, summary: a.summary || null,
|
|
@@ -161,15 +208,15 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
|
|
|
161
208
|
});
|
|
162
209
|
}
|
|
163
210
|
// Who runs it (FEP-baf5), each with the page a person is sent to.
|
|
164
|
-
const
|
|
165
|
-
|
|
211
|
+
const adminIds = ((await get(base + 'ap/administrators'))?.orderedItems || []).map(idOf).filter(Boolean);
|
|
212
|
+
const admins = await inTurn(adminIds, at, async (id) => {
|
|
166
213
|
const a = await get(id);
|
|
167
|
-
|
|
214
|
+
return {
|
|
168
215
|
id,
|
|
169
216
|
handle: a?.preferredUsername && a?.id ? `@${a.preferredUsername}@${new URL(a.id).host}` : authorLabel(id),
|
|
170
217
|
url: (typeof a?.url === 'string' && a.url) || id,
|
|
171
|
-
}
|
|
172
|
-
}
|
|
218
|
+
};
|
|
219
|
+
});
|
|
173
220
|
const heartbeat = await get(base + 'ap/heartbeat');
|
|
174
221
|
return { id: actor.id, name: actor.name || actor.preferredUsername, summary: actor.summary || null,
|
|
175
222
|
handle: actor.preferredUsername || null, categories, admins, lastHosted: heartbeat?.at || null };
|
|
@@ -251,8 +298,10 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
|
|
|
251
298
|
const ids = all.slice(from, from + limit);
|
|
252
299
|
const out = [];
|
|
253
300
|
const named = new Map();
|
|
254
|
-
|
|
255
|
-
|
|
301
|
+
const copies = await inTurn(ids, at, (url) => get(url));
|
|
302
|
+
await inTurn([...new Set(copies.map(c => idOf(c?.context)).filter(Boolean))], at, (t) => topicInfo(t, named));
|
|
303
|
+
for (const [i, url] of ids.entries()) {
|
|
304
|
+
const copy = copies[i];
|
|
256
305
|
if (!copy || copy.type === 'Tombstone') continue;
|
|
257
306
|
out.push({
|
|
258
307
|
id: copy.id || url,
|