fedipod-bb 0.4.4 → 0.5.1

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/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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 star across the whole forum, 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
@@ -36,14 +36,24 @@ const escText = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '
36
36
  const linkable = (href) => { try { return ['http:', 'https:', 'mailto:'].includes(new URL(href).protocol); } catch { return false; } };
37
37
  const inert = (html) => new DOMParser().parseFromString(html, 'text/html').body;
38
38
 
39
- function rebuild(node) {
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 }) {
40
46
  let out = '';
41
47
  for (const n of node.childNodes) {
42
48
  if (n.nodeType === 3) { out += escText(n.nodeValue); continue; }
43
49
  if (n.nodeType !== 1) continue;
44
50
  const tag = n.nodeName.toLowerCase();
45
51
  if (DROP.has(tag)) continue;
46
- 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
+ }
47
57
  let attrs = '';
48
58
  for (const a of n.attributes) {
49
59
  const name = a.name.toLowerCase();
@@ -51,14 +61,21 @@ function rebuild(node) {
51
61
  attrs += ` ${name}="${escText(a.value)}"`;
52
62
  }
53
63
  if (tag === 'a') attrs += ' rel="nofollow noopener noreferrer"';
54
- out += tag === 'br' ? '<br>' : `<${tag}${attrs}>${rebuild(n)}</${tag}>`;
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}>`;
55
72
  }
56
73
  return out;
57
74
  }
58
75
 
59
- export function cleanHtml(html, parse = inert) {
76
+ export function cleanHtml(html, parse = inert, { drop = null } = {}) {
60
77
  const text = textOf(html);
61
- return text ? rebuild(parse(text)) : '';
78
+ return text ? rebuild(parse(text), drop) : '';
62
79
  }
63
80
 
64
81
  // Where a forum is read from: through a Gateway at `<origin>/u/<handle>/`, or
@@ -81,7 +98,28 @@ export function placeOf({ origin, pathname = '/', search = '' }) {
81
98
  const fromPath = own ? decodeURIComponent(pathname.split('/').filter(Boolean)[0] || '') : '';
82
99
  const handle = params.get('forum') || fromPath || null;
83
100
  const pod = params.get('pod');
84
- return { front, handle, pod, base: pod || handle ? forumBase({ origin: front, handle, pod }) : null };
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;
85
123
  }
86
124
 
87
125
  // The name a cached copy is filed under (the same digest the host uses).
@@ -100,7 +138,9 @@ export function authorLabel(actorId) {
100
138
  } catch { return String(actorId); }
101
139
  }
102
140
 
103
- export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session = null } = {}) {
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 } = {}) {
104
144
  // A topic's name and how many posts are in it, read once however many of
105
145
  // its posts a feed carries.
106
146
  const topicInfo = async (topicId, seen) => {
@@ -150,12 +190,13 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
150
190
  const actor = await get(base + 'ap/actor');
151
191
  if (!actor) return null;
152
192
  const list = await get(base + 'ap/categories');
153
- const categories = [];
154
- for (const id of (list?.orderedItems || [])) {
193
+ const read = await inTurn(list?.orderedItems || [], at, async (id) => {
155
194
  const a = await get(id);
156
- if (!a) continue;
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)) {
157
199
  const cbase = categoryBase(a.id || id);
158
- const followers = await get(cbase + 'ap/followers');
159
200
  categories.push({
160
201
  id: a.id || id, base: cbase, slug: a.preferredUsername || null,
161
202
  name: a.name || a.preferredUsername || id, summary: a.summary || null,
@@ -167,15 +208,15 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
167
208
  });
168
209
  }
169
210
  // Who runs it (FEP-baf5), each with the page a person is sent to.
170
- const admins = [];
171
- for (const id of ((await get(base + 'ap/administrators'))?.orderedItems || []).map(idOf).filter(Boolean)) {
211
+ const adminIds = ((await get(base + 'ap/administrators'))?.orderedItems || []).map(idOf).filter(Boolean);
212
+ const admins = await inTurn(adminIds, at, async (id) => {
172
213
  const a = await get(id);
173
- admins.push({
214
+ return {
174
215
  id,
175
216
  handle: a?.preferredUsername && a?.id ? `@${a.preferredUsername}@${new URL(a.id).host}` : authorLabel(id),
176
217
  url: (typeof a?.url === 'string' && a.url) || id,
177
- });
178
- }
218
+ };
219
+ });
179
220
  const heartbeat = await get(base + 'ap/heartbeat');
180
221
  return { id: actor.id, name: actor.name || actor.preferredUsername, summary: actor.summary || null,
181
222
  handle: actor.preferredUsername || null, categories, admins, lastHosted: heartbeat?.at || null };
@@ -257,8 +298,10 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
257
298
  const ids = all.slice(from, from + limit);
258
299
  const out = [];
259
300
  const named = new Map();
260
- for (const url of ids) {
261
- const copy = await get(url);
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];
262
305
  if (!copy || copy.type === 'Tombstone') continue;
263
306
  out.push({
264
307
  id: copy.id || url,
@@ -0,0 +1,2 @@
1
+ User-agent: *
2
+ Disallow: /
@@ -490,14 +490,21 @@ export class ForumAgent {
490
490
  // A pin that holds across the whole forum, not just one category: the site
491
491
  // actor has a featured collection of its own, and this is it.
492
492
  async sitePin(topicId, on) {
493
- const { orderedCollection } = await import('fedipod/core/wire.mjs');
494
- const collection = await import('fedipod/pod/collection.mjs');
495
493
  const held = this.store.read('sitepins.json', []).filter(id => id !== topicId);
496
494
  const ids = on ? [topicId, ...held] : held;
497
495
  this.store.write('sitepins.json', ids);
496
+ await this.publishSitePins();
497
+ return { pinned: ids };
498
+ }
499
+
500
+ // The forum-wide pinned topics, from the forum's own record.
501
+ async publishSitePins() {
502
+ const { orderedCollection } = await import('fedipod/core/wire.mjs');
503
+ const collection = await import('fedipod/pod/collection.mjs');
504
+ const ids = this.store.read('sitepins.json', []);
498
505
  await collection.writeFlat(this.remote, this.site.featured,
499
506
  orderedCollection(this.site.featured, ids), { publicRead: true });
500
- return { pinned: ids };
507
+ return ids;
501
508
  }
502
509
 
503
510
  // A post from someone who is not a member. 'members' drops it, which is
@@ -710,6 +717,52 @@ export class ForumAgent {
710
717
  }
711
718
  }
712
719
 
720
+ // Copies a pod should hold and does not, as on a pod the forum has just
721
+ // moved to: each post's readable copy read again from where its author
722
+ // keeps it, with its counts from the forum's own record, and each author's
723
+ // card. A copy the pod will not say anything about is left alone. Returns
724
+ // how many posts were copied again.
725
+ async refillCopies() {
726
+ let filled = 0;
727
+ const missing = async (url) => { try { return (await this.remote.getJson(url)) === null; } catch { return false; } };
728
+ for (const cat of this.categories) {
729
+ const votes = cat.store.read('votes.json', {});
730
+ for (const entry of topics.list(cat.store)) {
731
+ const doc = topics.get(cat.store, entry.tid);
732
+ for (const p of doc?.posts || []) {
733
+ if (!await missing(cat.urls.cached(p.id))) continue;
734
+ const note = await cat.intake.fetchAP(p.id).catch(() => null) || await this.goneAt(p.id);
735
+ // Taken down by its author: the copy says so, as the forum's own does
736
+ // when the author's Delete arrives.
737
+ if (note?.type === 'Tombstone') {
738
+ await publish.tombstoneCached(cat, p.id, { formerType: note.formerType || 'Note' });
739
+ filled += 1;
740
+ continue;
741
+ }
742
+ if (!note?.id) { this.log(`${p.id}: no copy here, and its author's server did not give it`); continue; }
743
+ const v = votes[p.id];
744
+ await publish.cachePost(cat, note, { topic: cat.urls.topic(entry.tid),
745
+ replies: doc.posts.filter(x => x.inReplyTo === p.id).length,
746
+ likes: Array.isArray(v) ? v.length : (v?.up || []).length, dislikes: Array.isArray(v) ? 0 : (v?.down || []).length });
747
+ filled += 1;
748
+ if (p.author && await missing(cat.urls.cached(p.author))) {
749
+ const card = await cat.intake.fetchAP(p.author).catch(() => null);
750
+ if (card?.id) await publish.cacheAuthor(cat, card).catch(e => this.log(`author card for ${p.author}: ${e.message}`));
751
+ }
752
+ }
753
+ }
754
+ }
755
+ return filled;
756
+ }
757
+
758
+ // A post its author's server says is gone (410), as the Tombstone it is.
759
+ async goneAt(url) {
760
+ const r = await (this.probeFetch || globalThis.fetch)(url, { headers: { accept: 'application/activity+json' } }).catch(() => null);
761
+ if (r?.status !== 410) return null;
762
+ const body = await r.json().catch(() => ({}));
763
+ return { type: 'Tombstone', formerType: body?.formerType || 'Note' };
764
+ }
765
+
713
766
  rebuildLatest() {
714
767
  const rows = [];
715
768
  for (const cat of this.categories) {
@@ -807,7 +860,12 @@ export class ForumAgent {
807
860
  // that publish is forgotten here and the publish below writes it.
808
861
  if (redo) this.store.write('published.json', { ...this.store.read('published.json', {}), categories: null, latest: null, administrators: null });
809
862
  const seen = this.store.read('published.json', {});
810
- if (force || !seen.actorDigest) await this.siteAgent.publisher.publishProfile({ force });
863
+ if (force || !seen.actorDigest) {
864
+ const profile = await this.siteAgent.publisher.publishProfile({ force });
865
+ // The profile publish wrote the forum's featured list as its pinned
866
+ // posts, of which a forum has none: the forum-wide pinned topics go back.
867
+ if (!profile?.skipped) await this.publishSitePins();
868
+ }
811
869
  await publish.publishCategories(this.siteAgent, this.categories.map(c => c.urls.actor), { force });
812
870
  if (!this.store.read('latest.json', []).length) {
813
871
  const n = this.rebuildLatest();