fedipod-bb 0.2.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/site/bb.js ADDED
@@ -0,0 +1,1644 @@
1
+ // bb.js — the forum website: reads a forum from its pod (through this site's
2
+ // own /u/<handle>/ when the forum is attached here, or a pod named outright)
3
+ // and shows categories, topics and threads. Replying signs the reader in
4
+ // with a Mastodon account and posts from it; the reply appears here once the
5
+ // forum's host has placed it, and until then the reader sees their own copy
6
+ // marked as waiting.
7
+ //
8
+ // /bb/?forum=<handle> a forum attached to this site
9
+ // /bb/?pod=https://…/fedipod-bb/ a forum read from its pod directly
10
+ // https://bb.<site>/<handle>/ the same forum at its own address
11
+ // #/ #/c/<slug> #/t/<slug>/<tid>
12
+
13
+ import { reader, placeOf, authorLabel, cacheKey, categoryBase } from './read.mjs';
14
+ import { MastoLogin, hostOfHandle, serverKind, actorOfHandle } from './masto.mjs';
15
+ import * as pod from './pod.mjs';
16
+ import { mine as keptByReader } from './mine.mjs';
17
+ import { readState } from './seen.mjs';
18
+ import { toHtml } from './markdown.mjs';
19
+
20
+ const $ = (id) => document.getElementById(id);
21
+ // Said to a screen reader when the page changes under it and no focus moves:
22
+ // what is loading, how much arrived, that a link was copied.
23
+ const say = (msg) => { const el = $('say'); if (el) el.textContent = msg; };
24
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
25
+ const when = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10); };
26
+ const ago = (iso) => {
27
+ const ms = Date.now() - Date.parse(iso);
28
+ if (!Number.isFinite(ms)) return '';
29
+ const m = Math.round(ms / 60_000);
30
+ if (m < 2) return 'just now';
31
+ if (m < 90) return `${m} min ago`;
32
+ const h = Math.round(m / 60);
33
+ if (h < 36) return `${h} h ago`;
34
+ return `${Math.round(h / 24)} days ago`;
35
+ };
36
+
37
+ const params = new URLSearchParams(location.search);
38
+ const place = placeOf({ origin: location.origin, pathname: location.pathname, search: location.search });
39
+ const { front, base } = place;
40
+ const frontHost = new URL(front).host;
41
+ // The reader asks anonymously first; where a category is members-only it
42
+ // asks again with the pod session, if there is one.
43
+ let read = reader();
44
+ const login = new MastoLogin({ storage: localStorage, redirectUri: location.origin + location.pathname + location.search });
45
+ // What this reader has already read. Theirs, in their own browser: a public
46
+ // forum has nowhere to keep it and no business keeping it.
47
+ const seen = base ? readState({ storage: localStorage, forum: base }) : null;
48
+
49
+ let forum = null;
50
+ let podAcct = null; // { handle, actor, webId } when signed in with a pod
51
+ let moderators = []; // the category's own list, read from the forum
52
+ const account = () => podAcct || login.account();
53
+ const cats = () => forum?.categories || [];
54
+ const iModerate = () => {
55
+ const me = podAcct?.actor || login.account()?.url;
56
+ return !!me && moderators.includes(me);
57
+ };
58
+ const catBySlug = (slug) => cats().find(c => c.slug === slug || c.base.endsWith('/c/' + slug + '/'));
59
+ const handleOf = (cat) => {
60
+ // Through a Gateway a category answers as @slug@gateway; read from a pod, its id is its address.
61
+ if (place.handle && cat.slug) return `@${cat.slug}@${frontHost}`;
62
+ return cat.id;
63
+ };
64
+ // A post names the category it is for, and every reader of this page is
65
+ // already in it: the address is how it got here, not something to read.
66
+ function body(p, cat) {
67
+ const html = p?.content || '';
68
+ if (!cat) return html;
69
+ const d = document.createElement('div');
70
+ d.innerHTML = html;
71
+ for (const a of [...d.querySelectorAll('a')]) {
72
+ const names = a.getAttribute('href') === cat.id
73
+ || a.textContent.trim().replace(/^@/u, '') === String(cat.slug || '')
74
+ || a.textContent.trim() === handleOf(cat);
75
+ if (!names) continue;
76
+ const p0 = a.closest('p') || a.parentElement;
77
+ a.remove();
78
+ if (p0 && !p0.textContent.trim() && !p0.querySelector('img,a')) p0.remove();
79
+ }
80
+ return d.innerHTML;
81
+ }
82
+
83
+ // The author as they are known: their name when the forum kept a card for
84
+ // them, and always the handle, which is the thing that is theirs everywhere.
85
+ function byline(p, author) {
86
+ if (!author) return esc(p.author ? authorLabel(p.author) : '');
87
+ const handle = esc(author.handle);
88
+ const name = author.name && author.name !== author.handle ? esc(author.name) : null;
89
+ const inner = name ? `${name} <span class="dim">${handle}</span>` : handle;
90
+ // Their posts in this forum, not their page elsewhere: that is what a
91
+ // reader here is asking for when they click a name.
92
+ return `<a href="#/who/${encodeURIComponent(author.id)}">${inner}</a>`;
93
+ }
94
+
95
+ // Where this post can be READ away from here. A pod answers its post's own
96
+ // address with JSON, so that address is no use to a person and is not shown;
97
+ // a server that publishes a page for it says so, and the link names its host.
98
+ function elsewhere(p) {
99
+ if (!p?.page) return '';
100
+ let host = '';
101
+ try { host = new URL(p.page).host; } catch { return ''; }
102
+ return ` <a href="${esc(p.page)}" class="dim" rel="noopener">on ${esc(host)}</a>`;
103
+ }
104
+
105
+ // Whose post this is, as this browser can tell: a pod account is its actor,
106
+ // a Mastodon account is the profile its server gave us.
107
+ const mine = (p) => {
108
+ const me = podAcct?.actor || login.account()?.url;
109
+ return !!me && !!p?.author && (p.author === me || p.author === podAcct?.webId);
110
+ };
111
+
112
+ // A thread in the shape it was written: each reply under the post it answers,
113
+ // and anything whose parent is not here at the top level. Depth is capped so
114
+ // a long argument does not walk off the side of the page.
115
+ const MAX_DEPTH = 6;
116
+ async function threaded(posts, authors, cat) {
117
+ const byId = new Map(posts.map(p => [p.id, p]));
118
+ const kids = new Map();
119
+ const roots = [];
120
+ for (const p of posts) {
121
+ const parent = p.inReplyTo && byId.has(p.inReplyTo) && p.inReplyTo !== p.id ? p.inReplyTo : null;
122
+ if (!parent) { roots.push(p); continue; }
123
+ if (!kids.has(parent)) kids.set(parent, []);
124
+ kids.get(parent).push(p);
125
+ }
126
+ const out = [];
127
+ const walk = async (p, depth) => {
128
+ const html = card(p, { author: p.author ? authors.get(p.author) : null, cat, anchor: await cacheKey(p.id) });
129
+ out.push(depth ? `<div class="nest" style="--depth:${Math.min(depth, MAX_DEPTH)}">${html}</div>` : html);
130
+ for (const k of kids.get(p.id) || []) await walk(k, depth + 1);
131
+ };
132
+ for (const r of roots) await walk(r, 0);
133
+ return out;
134
+ }
135
+
136
+ function topicActs(topicId) {
137
+ if (!iModerate()) return '';
138
+ return `<div class="acts mod" role="group" aria-label="Moderator actions for this topic" data-topic="${esc(topicId)}">
139
+ <button data-act="rename">Rename topic</button>
140
+ <button data-act="unlock">Reopen topic</button>
141
+ <button data-act="pin">Pin topic</button><button data-act="unpin">Unpin topic</button>
142
+ <button data-act="sitepin">Pin site-wide</button><button data-act="siteunpin">Unpin site-wide</button>
143
+ <button class="grave" data-act="lock">Close topic</button><button class="grave" data-act="droptopic">Delete topic</button>
144
+ </div>`;
145
+ }
146
+
147
+ // Voting, both ways, as a forum has always had it: up, down, and the one you
148
+ // cast again to take it back. A reader with no account sees the counts.
149
+ function votes(p) {
150
+ const up = p.likes || 0;
151
+ const down = p.dislikes || 0;
152
+ // Signed out, the buttons are still buttons: pressing one asks who you are
153
+ // and then casts the vote. Showing the counts as dead text hid the fact that
154
+ // voting was possible at all.
155
+ const mineWay = account() ? own().votedOn(p.id) : 'none';
156
+ const one = (way, mark, n, label) => `<button data-act="vote-${mineWay === way ? 'none' : way}"
157
+ data-was="${mineWay}"${mineWay === way ? ' aria-pressed="true" class="voted"' : ''}
158
+ aria-label="${mineWay === way ? `Take back your vote ${label}` : `Vote ${label} this post`}">${mark} ${n}</button>`;
159
+ return one('up', '▲', up, 'for') + one('down', '▼', down, 'against');
160
+ }
161
+
162
+ function card(p, { author = null, cat = null, extra = '', waiting = false, anchor = null } = {}) {
163
+ const at = p.published ? when(p.published) : '';
164
+ // What a screen reader hears before the words, and what each of this post's
165
+ // own buttons is called — a thread of them is not twenty identical "Reply"s.
166
+ const whose = esc(author ? (author.name || author.handle) : (p.author ? authorLabel(p.author) : 'someone'));
167
+ const named = `${waiting ? 'Waiting: p' : 'P'}ost by ${whose}${at ? `, ${esc(at)}` : ''}`;
168
+ return `<article class="post${p.gone ? ' gone' : ''}${extra}" aria-label="${named}"${anchor ? ` id="p-${esc(anchor)}"` : ''}>
169
+ <div class="who"><b>${waiting ? '<span class="dim">waiting for the forum · </span>' : ''}${byline(p, author)}</b>
170
+ <span class="when">${esc(at)}${elsewhere(p)}</span></div>
171
+ <div class="body">${p.gone ? 'This post was removed.' : (body(p, cat) || '<span class="dim">(not readable here)</span>')}</div>
172
+ ${p.gone || waiting || !p.id ? '' : `<div class="acts" data-post="${esc(p.id)}">
173
+ <button data-act="reply" aria-label="Reply to ${whose}">Reply</button>
174
+ ${votes(p)}
175
+ <button data-act="share" aria-label="Copy a link to the post by ${whose}">Share</button>
176
+ ${account() ? `<button data-act="${own().isSaved(p.id) ? 'unsave' : 'save'}" aria-label="${own().isSaved(p.id) ? 'Remove this post from your bookmarks' : 'Bookmark this post'}">${own().isSaved(p.id) ? 'Bookmarked' : 'Bookmark'}</button>` : ''}
177
+ ${mine(p) || !account() ? '' : `<button data-act="report" aria-label="Report the post by ${whose}">Report</button>`}
178
+ ${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>' : ''}
179
+ ${!mine(p) && iModerate() ? `<button data-act="remove" aria-label="Remove the post by ${whose}">Remove</button>` : ''}
180
+ </div>`}
181
+ </article>`;
182
+ }
183
+
184
+ const waitingKey = (topicId) => 'bb:waiting:' + topicId;
185
+ const waiting = (topicId) => { try { return (JSON.parse(localStorage.getItem(waitingKey(topicId)) || '[]') || []).filter(w => w?.id); } catch { return []; } };
186
+ // A copy is kept only for a post that is waiting to be PLACED, and only when
187
+ // it carries the id the topic will name it by: an entry nothing can match is
188
+ // an entry nothing can ever clear.
189
+ const remember = (topicId, entry) => {
190
+ if (!entry?.id) return;
191
+ try { localStorage.setItem(waitingKey(topicId), JSON.stringify([...waiting(topicId), entry].slice(-20))); } catch { /* full or blocked */ }
192
+ };
193
+
194
+ async function load() {
195
+ if (!base) {
196
+ $('main').innerHTML = `<p class="err">Name a forum: <code>${esc(location.origin)}/&lt;forum&gt;/</code></p>`;
197
+ return;
198
+ }
199
+ forum = await read.forum(base);
200
+ if (!forum) {
201
+ // A forum that does not exist and one whose pod is down read the same
202
+ // to the reader above; asking once more tells them apart, so a reader is
203
+ // never told a forum is gone when its server is only unreachable.
204
+ const r = await fetch(base + 'ap/actor', { headers: { accept: 'application/activity+json' } }).catch(() => null);
205
+ const missing = r && (r.status === 404 || r.status === 410);
206
+ $('main').innerHTML = missing
207
+ ? '<p class="err">No forum answers at this address.</p>'
208
+ : '<p class="err">The pod that holds this forum is not answering right now, so the forum cannot be shown. Try again later.</p>';
209
+ return;
210
+ }
211
+ document.title = forum.name || 'FediPod-BB';
212
+ $('forum-link').textContent = forum.name || 'Forum';
213
+ // Who moderates, under the line: the forum's own list, each name linking to
214
+ // the page that account keeps.
215
+ const mods = (forum.admins || []).map(a => `<a href="#/who/${encodeURIComponent(a.id)}">${esc(a.handle)}</a>`).join(', ');
216
+ $('mods-who').innerHTML = mods ? `<span class="lead">moderators:</span> ${mods}` : '';
217
+ route();
218
+ }
219
+
220
+ function crumbs(parts) {
221
+ // One stop is no trail: the front page needs no line saying it is the
222
+ // front page.
223
+ if (parts.length < 2) { $('crumbs').textContent = ''; $('crumbs').hidden = true; return; }
224
+ $('crumbs').hidden = false;
225
+ $('crumbs').innerHTML = parts.map(([label, href], i) => (href && i < parts.length - 1 ? `<a href="${esc(href)}">${esc(label)}</a>` : esc(label))).join(' › ');
226
+ }
227
+
228
+ // Who the page is holding, in the top row: the handle a reader posts under,
229
+ // and the way in when there is nobody. Signing in from here is what makes the
230
+ // moderator's Queue and Settings reachable at all.
231
+ function paintWho() {
232
+ const acct = account();
233
+ $('who-name').textContent = acct ? `signed in as ${acct.handle}` : '';
234
+ $('who-act').textContent = acct ? 'Sign out' : 'Sign in';
235
+ }
236
+
237
+ async function route() {
238
+ paintWho();
239
+ // Searching and starting a topic belong to the forum's own view; the rest of
240
+ // the page keeps the moderators line and nothing else.
241
+ $('mods-acts').innerHTML = '';
242
+ const mine = ++view;
243
+ void mine;
244
+ const hash = location.hash.replace(/^#\/?/u, '');
245
+ const [kind, a, b, c] = hash.split('/');
246
+ $('reply-row').hidden = true;
247
+ $('reply-dlg').close();
248
+ if (kind === 'replies') return showReplies();
249
+ if (kind === 'bookmarks' || kind === 'saved') return showSaved();
250
+ if (kind === 'queue') return showQueue();
251
+ if (kind === 'settings') return showSettings();
252
+ if (kind === 'who' && a) return showWho(decodeURIComponent(a));
253
+ if (kind === 'c' && a) { if (feedFilter !== a) shown = 30; feedFilter = a; return showForum(); }
254
+ if (kind === 't' && a && b) return showTopic(a, b, c || null);
255
+ // `#/` IS the whole forum, so arriving at it clears the category. Leaving the
256
+ // filter where it was meant Home redrew whatever category you had been in,
257
+ // and All — whose link is `#/` — redrew the same one, which from the reader's
258
+ // side was a button that did nothing at all.
259
+ if (feedFilter !== 'all') { feedFilter = 'all'; shown = 30; }
260
+ return showForum();
261
+ }
262
+
263
+ // The front page is an index of what has been said, newest first, across the
264
+ // forum: which category, which topic, who, and when. The words themselves are
265
+ // in the topic, one click away.
266
+ let feedFilter = 'all'; // 'all', or a category's slug
267
+ let shown = 30; // how many of the index's rows are on the page
268
+ let view = 0; // which view the page is on; an older one must not draw
269
+ let order = 'date'; // which column the index is ordered by
270
+ let down = true; // and which way
271
+ let finding = ''; // what the reader is looking for, if anything
272
+ // What this reader keeps for themselves, under the account they signed in as.
273
+ const own = () => keptByReader({ storage: localStorage, who: account()?.handle || null });
274
+
275
+ // Answers to this reader's own posts, newest first. The forum publishes what
276
+ // each post answers, so nobody has to be told whom to notify.
277
+ async function showReplies() {
278
+ crumbs([['Home', '#/'], ['Replies to you', null]]);
279
+ const me = podAcct?.actor || login.account()?.url;
280
+ if (!me) { $('main').innerHTML = '<p class="empty">Sign in to see answers to your posts.</p>'; return; }
281
+ $('main').innerHTML = '<p class="dim">Looking…</p>';
282
+ const latest = await read.latest(base, { limit: 200 });
283
+ const mineIds = new Set(latest.filter(p => p.author === me).map(p => p.id));
284
+ const answers = latest.filter(p => p.inReplyTo && mineIds.has(p.inReplyTo) && p.author !== me);
285
+ if (!answers.length) { $('main').innerHTML = '<p class="empty">Nobody has answered you yet.</p>'; return; }
286
+ const items = [];
287
+ for (const p of answers) {
288
+ const cat = cats().find(c => c.id === p.category) || null;
289
+ const who = cat && p.author ? await read.author(cat.base, p.author) : null;
290
+ const tid = p.topic ? p.topic.split('/').pop() : null;
291
+ const href = cat && tid ? `#/t/${esc(cat.slug)}/${esc(tid)}/${esc(await cacheKey(p.id))}` : '#/';
292
+ items.push(`<li><a class="title" href="${href}">${esc(p.topicName || 'Topic')}</a>
293
+ <div class="meta">${who ? esc(who.handle) : esc(authorLabel(p.author || ''))} · ${esc(when(p.published))}</div>
294
+ <article class="post"><div class="body">${p.content || ''}</div></article></li>`);
295
+ }
296
+ $('main').innerHTML = `<ul class="list">${items.join('')}</ul>`;
297
+ }
298
+
299
+ // Posts this reader kept. The list is theirs and lives in this browser.
300
+ function showSaved() {
301
+ crumbs([['Home', '#/'], ['Bookmarked', null]]);
302
+ const rows = own().saved();
303
+ if (!rows.length) { $('main').innerHTML = '<p class="empty">Nothing bookmarked yet. Use Bookmark on a post.</p>'; return; }
304
+ $('main').innerHTML = `<ul class="list">${rows.map(r => {
305
+ const cat = cats().find(c => c.slug === r.cat) || null;
306
+ const tid = r.topic ? r.topic.split('/').pop() : null;
307
+ const href = cat && tid ? `#/t/${esc(cat.slug)}/${esc(tid)}` : '#/';
308
+ return `<li><a class="title" href="${href}">${esc(r.text || r.id)}</a>
309
+ <div class="meta">bookmarked ${esc(when(r.at))}${cat ? ' · ' + esc(cat.name) : ''}</div></li>`;
310
+ }).join('')}</ul>`;
311
+ }
312
+
313
+ // The moderators' queue: reports, posts held from people who have not joined,
314
+ // and asks not yet acted on. Read from the forum's pod with the moderator's
315
+ // own login — it is not public and never passes through the Gateway.
316
+ async function showQueue() {
317
+ crumbs([['Home', '#/'], ['Queue', null]]);
318
+ if (!podAcct) { $('main').innerHTML = '<p class="empty">The queue is read with your pod account.</p>'; return; }
319
+ $('main').innerHTML = '<p class="dim">Reading the queue…</p>';
320
+ try {
321
+ const said = await serverKind(new URL(front).host, front, undefined, forum.handle || 'forum');
322
+ if (!said?.podHome) throw new Error('this forum does not say where its pod is');
323
+ const q = await pod.modQueue(said.podHome);
324
+ // Only what a moderator can act on: a report, a held post, a join
325
+ // request, and an ask that did not take. Another moderator's ask on its
326
+ // way through is the forum's business, not a row with no button.
327
+ const ACTS = new Set(['Flag', 'Held', 'Create', 'Join request']);
328
+ const rows = (q?.rows || []).filter(r => r.failed || ACTS.has(r.type));
329
+ if (!rows.length) { $('main').innerHTML = '<p class="empty">Nothing is waiting.</p>'; return; }
330
+ $('main').innerHTML = `<ul class="list">${rows.map(r => `<li>
331
+ <div class="title">${esc(r.type)}${r.category ? ' · ' + esc(r.category) : ''}</div>
332
+ <div class="meta">${r.type === 'Flag' ? 'reported by ' : ''}${esc(r.by ? authorLabel(r.by) : '')}${r.about ? ` · about <a href="#/who/${encodeURIComponent(r.about)}">${esc(authorLabel(r.about))}</a>` : ''} · ${esc(when(r.at))}${r.verified ? ' · checked' : ''}</div>
333
+ ${r.object ? `<div class="dim">${esc(r.object)}</div>` : ''}
334
+ ${r.failed ? `<p class="err">It did not take: ${esc(r.failed)}${r.failedAt ? ` (${esc(when(r.failedAt))})` : ''}. It is still here and will be tried again.</p>` : ''}
335
+ ${r.why ? `<article class="post"><div class="body">${esc(r.why)}</div></article>` : ''}
336
+ ${r.object ? `<div class="acts mod" data-post="${esc(r.object)}" data-cat="${esc(r.category || '')}">
337
+ ${r.type === 'Held' || r.type === 'Create' ? '<button data-act="approve">Let it through</button><button data-act="refuse">Turn it away</button>' : ''}
338
+ ${r.type === 'Join request' ? '<button data-act="admit">Admit</button><button data-act="refuse">Turn them away</button>' : ''}
339
+ ${r.about ? '<button data-act="ban" data-who="' + esc(r.about) + '">Ban the author</button>' : ''}
340
+ ${r.about && r.object ? '<button data-act="removepost" data-post="' + esc(r.object) + '">Remove the post</button>' : ''}
341
+ ${r.type !== 'Flag' && r.by ? '<button data-act="ban" data-who="' + esc(r.by) + '">Ban them</button>' : ''}
342
+ </div>` : ''}
343
+ </li>`).join('')}</ul>`;
344
+ } catch (e) { $('main').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
345
+ }
346
+
347
+ async function showWho(handleOrId) {
348
+ crumbs([['Home', '#/'], ['Someone', null]]);
349
+ $('main').innerHTML = '<p class="dim">Looking…</p>';
350
+ const latest = await read.latest(base, { limit: 200 });
351
+ const theirs = latest.filter(p => p.author === handleOrId);
352
+ const cat = theirs[0] ? cats().find(c => c.id === theirs[0].category) : cats()[0];
353
+ // What the forum kept of them, and failing that their own actor: a moderator
354
+ // who has never posted here has no card, and this page is where every handle
355
+ // in the forum now leads.
356
+ const kept = cat ? await read.author(cat.base, handleOrId) : null;
357
+ // The copy the forum kept may predate the account saying where its profile
358
+ // is, so where it names no page the account itself is asked.
359
+ const said = kept?.url ? null : await read.actorCard(handleOrId).catch(() => null);
360
+ const card = kept && said ? { ...kept, url: said.url, name: kept.name || said.name } : (kept || said);
361
+ crumbs([['Home', '#/'], [card?.handle || authorLabel(handleOrId), null]]);
362
+ const named = esc(card?.handle || authorLabel(handleOrId));
363
+ // A word with them and nobody else. Only from a pod account: the message is
364
+ // written on the sender's own pod before it is handed over.
365
+ const messageBox = podAcct && podAcct.actor !== handleOrId ? `<section class="reply">
366
+ <h2>Post to ${named}'s timeline</h2>
367
+ <p class="hint">A post of yours that names them, so it reaches them and anyone who reads either of
368
+ you. It is public: everybody can read it. Your own account posts it and signs it, so it reaches
369
+ Mastodon and the rest of the Fediverse — which means it goes out when your agent next reads its
370
+ inbox, and that it needs an account fronted at this Gateway.</p>
371
+ <label class="vh" for="dm-text">Your post</label>
372
+ <textarea id="dm-text" rows="3" placeholder="Your post"></textarea>
373
+ <p class="row"><button class="primary" data-act="post-to" data-to="${esc(handleOrId)}"
374
+ data-handle="${esc(card?.handle || '')}">Post</button></p>
375
+ <p class="err" id="dm-err" role="alert"></p>
376
+ </section>` : '';
377
+ const head = `<div class="topline"><h1>${esc(card?.name || card?.handle || authorLabel(handleOrId))}</h1></div>
378
+ <p class="hint">${named}${card?.url ? ` · <a href="${esc(card.url)}">their profile</a>` : ''}
379
+ · ${theirs.length} post${theirs.length === 1 ? '' : 's'} here</p>${messageBox}`;
380
+ if (!theirs.length) { $('main').innerHTML = head + '<p class="empty">Nothing from them in what the forum is holding.</p>'; return; }
381
+ const items = [];
382
+ for (const p of theirs) {
383
+ const c = cats().find(x => x.id === p.category) || null;
384
+ const tid = p.topic ? p.topic.split('/').pop() : null;
385
+ const href = c && tid ? `#/t/${esc(c.slug)}/${esc(tid)}/${esc(await cacheKey(p.id))}` : '#/';
386
+ items.push(`<li><a class="title" href="${href}">${esc(p.topicName || 'Topic')}</a>
387
+ <div class="meta">${c ? esc(c.name) + ' · ' : ''}${esc(when(p.published))} · ▲ ${p.likes || 0} ▼ ${p.dislikes || 0}</div>
388
+ <article class="post"><div class="body">${p.content || ''}</div></article></li>`);
389
+ }
390
+ $('main').innerHTML = head + `<ul class="list">${items.join('')}</ul>`;
391
+ }
392
+
393
+ // What a moderator may change about the forum itself. Every change is a
394
+ // request published at the moderator's own pod and checked there, so this
395
+ // page needs no rights on the forum's pod at all.
396
+ // Who moderates is learned while the forum is drawn. A page opened straight
397
+ // at a link — Settings, say, or a bookmark — has drawn nothing yet, and would
398
+ // take a moderator for a stranger and turn them away.
399
+ async function knowModerators() {
400
+ if (moderators.length || !cats().length) return;
401
+ const lists = await Promise.all(cats().map(c => read.moderators(c.base).catch(() => [])));
402
+ moderators = [...new Set(lists.flat())];
403
+ }
404
+
405
+ async function showSettings() {
406
+ crumbs([['Home', '#/'], ['Settings', null]]);
407
+ await knowModerators();
408
+ if (!iModerate()) { $('main').innerHTML = '<p class="empty">Only this forum\'s moderators may change it.</p>'; return; }
409
+ if (!podAcct) { $('main').innerHTML = '<p class="empty">Changing the forum is done with your pod account.</p>'; return; }
410
+ $('main').innerHTML = `
411
+ <div class="topline"><h1>Settings</h1></div>
412
+ <section class="reply">
413
+ <h2>The forum</h2>
414
+ <div class="row oneline">
415
+ <label for="set-forum-name">Name</label>
416
+ <input type="text" id="set-forum-name" value="${esc(forum.name || '')}">
417
+ <button data-act="set-forum-name">Rename</button>
418
+ <button data-act="open-mods">Manage moderators</button>
419
+ </div>
420
+ </section>
421
+ <section class="reply">
422
+ <h2>Categories</h2>
423
+ <ul class="list bare">${cats().map(c => `<li>
424
+ <div class="row">
425
+ <input type="text" class="cat-name" data-cat="${esc(c.id)}" value="${esc(c.name)}">
426
+ <button data-act="set-cat-name" data-cat="${esc(c.id)}">Rename</button>
427
+ <button data-act="open-members" data-cat="${esc(c.id)}">Manage members</button>
428
+ <span class="dim">${esc(handleOf(c))}</span>
429
+ <label><input type="radio" name="cat-private-${esc(c.slug || c.id)}" data-act="set-cat-private" data-cat="${esc(c.id)}" value="open"${c.private ? '' : ' checked'}> Open</label>
430
+ <label><input type="radio" name="cat-private-${esc(c.slug || c.id)}" data-act="set-cat-private" data-cat="${esc(c.id)}" value="private"${c.private ? ' checked' : ''}> Private</label>
431
+ </div>
432
+ </li>`).join('')}</ul>
433
+ </section>
434
+ <section class="reply">
435
+ <h2>New Category</h2>
436
+ <div class="row oneline">
437
+ <label for="set-new-name">Category name</label>
438
+ <input type="text" id="set-new-name">
439
+ <label for="set-new-slug">Category slug</label>
440
+ <input type="text" id="set-new-slug" placeholder="Fediverse Username" size="18">
441
+ </div>
442
+ <div class="row">
443
+ <label><input type="radio" name="set-new-private" value="open" checked> Open</label>
444
+ <label><input type="radio" name="set-new-private" value="private"> Private</label>
445
+ <button data-act="add-cat">Create</button>
446
+ </div>
447
+ </section>
448
+ `;
449
+ }
450
+
451
+ // The index, read once and filtered here. Changing category, ordering it,
452
+ // or typing in the search box changes NOTHING about what the forum holds — yet
453
+ // each of them refetched every post, every category's moderators and every
454
+ // category's pinned topics, and blanked the page to "Loading…" while it went.
455
+ // Searching did it on every keystroke.
456
+ //
457
+ // So it is read once and kept: whole, unfiltered, for every category, because
458
+ // which subset is on screen is the reader's business and not the network's.
459
+ // Thrown away after a minute, when more rows are asked for than were fetched,
460
+ // and by hand whenever this page writes something the forum will show back.
461
+ let index = null;
462
+ const INDEX_STALE_MS = 60_000;
463
+ function forgetIndex() { index = null; authors.clear(); }
464
+
465
+ // Who wrote it, and the digest a topic link carries. Both are asked once per
466
+ // ROW, and the same people and the same posts come round again every time the
467
+ // index is redrawn — so each was a fresh lookup for an answer that cannot have
468
+ // changed. Serially, too: thirty rows was thirty round trips one after another,
469
+ // which is where the wait after choosing a category actually went.
470
+ const authors = new Map();
471
+ const digests = new Map();
472
+ function authorOnce(catBase, id) {
473
+ const key = `${catBase}|${id}`;
474
+ if (!authors.has(key)) authors.set(key, read.author(catBase, id).catch(() => null));
475
+ return authors.get(key);
476
+ }
477
+ function digestOnce(id) {
478
+ if (!digests.has(id)) digests.set(id, cacheKey(id));
479
+ return digests.get(id);
480
+ }
481
+
482
+ async function readIndex(want) {
483
+ if (index && index.limit >= want && Date.now() - index.at < INDEX_STALE_MS) return index;
484
+ const latest = await read.latest(base, { limit: want });
485
+ const site = new Set(await read.featured(base));
486
+ const pinned = new Map();
487
+ const mods = new Map();
488
+ for (const c of cats()) {
489
+ pinned.set(c.id, new Set(await read.featured(c.base)));
490
+ mods.set(c.id, await read.moderators(c.base));
491
+ }
492
+ index = { at: Date.now(), limit: want, latest, site, pinned, mods };
493
+ return index;
494
+ }
495
+
496
+ async function showForum() {
497
+ const mineView = view;
498
+ const here = cats().find(c => c.slug === feedFilter) || null;
499
+ crumbs(here ? [['Home', '#/'], [here.name, `#/c/${here.slug}`]] : [['Home', '#/']]);
500
+ // Who moderates what is on screen. Showing the whole forum, that is
501
+ // whoever moderates its categories — otherwise a moderator looking at
502
+ // everything would look like a stranger.
503
+ // Moderators come out of the index below, which holds them for every
504
+ // category — so this no longer decides what to fetch, only what to show.
505
+ const showMods = (idx) => {
506
+ if (here) { moderators = idx.mods.get(here.id) || []; } else {
507
+ const all = new Set();
508
+ for (const list of idx.mods.values()) for (const m of list) all.add(m);
509
+ moderators = [...all];
510
+ }
511
+ // The line under the rule says who they are. It is painted from the
512
+ // forum's own list when the page loads; this keeps it with the categories'
513
+ // lists, which is where a moderator added while you read appears first.
514
+ paintMods(moderators);
515
+ };
516
+ const chip = (slug, label) => `<a class="chip${feedFilter === slug ? ' on' : ''}"${feedFilter === slug ? ' aria-current="page"' : ''} href="${slug === 'all' ? '#/' : `#/c/${esc(slug)}`}">${esc(label)}</a>`;
517
+ // Starting a topic from the front page: in the category being shown, or the
518
+ // first one when the whole forum is.
519
+ const into = cats().find(c => c.slug === feedFilter) || cats()[0] || null;
520
+ // Built where it is drawn, not once above: whether this reader moderates is
521
+ // read with the index below, and a row composed before that always said no.
522
+ const head = () => `<p class="hint chips">Categories: ${chip('all', 'All')}${cats().map(c => chip(c.slug || '', c.name)).join('')}
523
+ ${account() ? '<a class="chip" href="#/replies">Replies to you</a><a class="chip" href="#/bookmarks">Bookmarked</a>' : ''}
524
+ ${iModerate() ? '<a class="chip" href="#/queue">Queue</a><a class="chip" href="#/settings">Settings</a>' : ''}
525
+ <span class="rowend">
526
+ ${here && account() && podAcct ? `<button class="new" data-act="${own().isJoined(here.id) ? 'leave' : 'join'}" data-cat="${esc(here.id)}">${own().isJoined(here.id) ? 'Leave' : 'Join'}</button>` : ''}
527
+ </span></p>`;
528
+ // Search and New topic ride at the right end of the moderators row, which is
529
+ // the one line the page keeps above whatever is being read.
530
+ const acts = () => `<label class="vh" for="find">Search this forum</label>
531
+ <input type="text" id="find" placeholder="Search" value="${esc(finding)}" autocomplete="off"${finding ? '' : ' hidden'}>
532
+ <button data-act="search"${finding ? ' hidden' : ''}>Search</button>
533
+ ${into ? `<button data-act="newtopic" data-slug="${esc(into.slug || '')}">New topic</button>` : ''}`;
534
+ if (mineView !== view) return;
535
+ // Enough posts to fill a page of TOPICS: several posts can belong to one.
536
+ const want = Math.min(300, shown * 5);
537
+ // Only say "Loading" when something is actually going to be waited for. The
538
+ // reader changing category has everything already; blanking the page for
539
+ // them was the whole of the delay they could see.
540
+ const willFetch = !(index && index.limit >= want && Date.now() - index.at < INDEX_STALE_MS);
541
+ if (willFetch) {
542
+ $('mods-acts').innerHTML = acts();
543
+ $('main').innerHTML = head() + '<p class="dim">Loading…</p>';
544
+ say('Loading the latest posts');
545
+ }
546
+ const idx = await readIndex(want);
547
+ if (mineView !== view) return;
548
+ showMods(idx);
549
+ const latest = idx.latest;
550
+ const looking = finding.trim().toLowerCase();
551
+ const mine = latest.filter(p => (feedFilter === 'all'
552
+ || (cats().find(c => c.id === p.category)?.slug === feedFilter))
553
+ // Searching what the page has: a topic's name, an author's handle, and
554
+ // the words of the post itself.
555
+ && (!looking || [p.topicName, p.author, p.content].some(v => String(v || '').toLowerCase().includes(looking))));
556
+ // Pinned topics first. A pin belongs to a category; the forum has a
557
+ // featured collection of its own for a pin that holds everywhere.
558
+ const pins = { cat: new Set(), site: idx.site };
559
+ for (const c of cats()) {
560
+ if (feedFilter !== 'all' && c.slug !== feedFilter) continue;
561
+ for (const id of idx.pinned.get(c.id) || []) pins.cat.add(id);
562
+ }
563
+ // One row per topic: the newest post in it stands for it, which is what a
564
+ // reader wants to open — the thing they have not read.
565
+ const byTopic = new Map();
566
+ for (const p of mine) {
567
+ if (!p.topic) continue;
568
+ const held = byTopic.get(p.topic);
569
+ if (!held || String(p.published || '') > String(held.published || '')) byTopic.set(p.topic, p);
570
+ }
571
+ const rank = (p) => (pins.site.has(p.topic) ? 2 : pins.cat.has(p.topic) ? 1 : 0);
572
+ // Pinned first whatever the order; then the column the reader chose.
573
+ // Posts, not replies: a topic with nobody answering yet still HAS the post
574
+ // that started it, and a column reading 0 beside a topic you can open and
575
+ // read says the wrong thing. The count the forum keeps is of replies, so the
576
+ // opening post is the one added here.
577
+ const by = (p) => (order === 'replies' ? (p.topicReplies || 0) + 1 : String(p.published || ''));
578
+ const topics = [...byTopic.values()].sort((a, b) => rank(b) - rank(a)
579
+ || (down ? (by(a) > by(b) ? -1 : by(a) < by(b) ? 1 : 0) : (by(a) > by(b) ? 1 : by(a) < by(b) ? -1 : 0)));
580
+ const page = topics.slice(0, shown);
581
+ if (!topics.length) {
582
+ // A members-only category that will not answer this reader looks exactly
583
+ // like an empty one. Ask it a question only a member can have answered.
584
+ if (here && !(await read.topics(here.base)).total && !(await read.canRead(here.base))) {
585
+ if (mineView !== view) return;
586
+ $('mods-acts').innerHTML = acts();
587
+ $('main').innerHTML = head() + (podAcct
588
+ ? `<p class="empty row">This category is for its members.
589
+ <button class="new" data-act="ask-join" data-cat="${esc(here.id)}">Request membership</button></p>`
590
+ : `<p class="empty row">This category is for its members, and membership needs a FediPod account.
591
+ <a class="big" href="${esc(front)}/new-account">Get a FediPod account</a></p>`);
592
+ say('This category is for its members');
593
+ return;
594
+ }
595
+ // The one thing to do on an empty page is the one thing offered.
596
+ if (mineView !== view) return;
597
+ $('mods-acts').innerHTML = acts();
598
+ $('main').innerHTML = head() + '<p class="empty">Nothing posted here yet. Use the button at the upper right to create a topic.</p>';
599
+ say('Nothing posted here yet');
600
+ return;
601
+ }
602
+ const rows = [];
603
+ let fresh = 0;
604
+ // Every row's author and digest at once rather than one row at a time: they
605
+ // do not depend on each other, and waiting for each in turn is the whole of
606
+ // the delay between choosing a category and seeing it.
607
+ const looked = await Promise.all(page.map(async (p) => {
608
+ const cat = cats().find(c => c.id === p.category) || null;
609
+ return {
610
+ cat,
611
+ who: cat && p.author ? await authorOnce(cat.base, p.author) : null,
612
+ digest: await digestOnce(p.id),
613
+ };
614
+ }));
615
+ if (mineView !== view) return;
616
+ for (let i = 0; i < page.length; i++) {
617
+ const p = page[i];
618
+ const { cat, who, digest } = looked[i];
619
+ const tid = p.topic ? p.topic.split('/').pop() : null;
620
+ const href = cat && tid ? `#/t/${esc(cat.slug)}/${esc(tid)}/${esc(digest)}` : null;
621
+ const name = esc(p.topicName || 'Topic');
622
+ // New to THIS reader: posted since they last had the topic open.
623
+ const isNew = !!seen?.isNew(p.topic, p.published);
624
+ if (isNew) fresh += 1;
625
+ const byline = who ? who.handle : (p.author ? authorLabel(p.author) : '');
626
+ rows.push(`<tr>
627
+ <td>${pins.site.has(p.topic) ? '<span class="pin" title="Pinned across the forum" role="img" aria-label="pinned across the forum">\u2B50</span> ' : pins.cat.has(p.topic) ? '<span class="pin" title="Pinned in this category" role="img" aria-label="pinned in this category">\uD83D\uDCCC</span> ' : ''}${href ? `<a href="${href}">${name}</a>` : name}${isNew
628
+ ? ' <span class="badge">New<span class="vh"> since you last opened this topic</span></span>' : ''}</td>
629
+ <td>${cat ? esc(cat.name) : ''}</td>
630
+ <td>${byline ? (p.author
631
+ ? `<a class="who" href="#/who/${encodeURIComponent(p.author)}" title="${esc(byline)}">${esc(byline)}</a>`
632
+ : `<span class="who" title="${esc(byline)}">${esc(byline)}</span>`) : ''}</td>
633
+ <td>${esc(when(p.published))}</td>
634
+ <td>${Number.isFinite(p.topicReplies) ? p.topicReplies + 1 : ''}</td>
635
+ </tr>`);
636
+ }
637
+ // A table, so a reader can run their eye down any one of the four things
638
+ // an entry says.
639
+ const more = Math.max(0, topics.length - page.length) + (latest.more || 0);
640
+ if (mineView !== view) return;
641
+ $('mods-acts').innerHTML = acts();
642
+ $('main').innerHTML = head() + `<div class="scroll" role="region" aria-label="Topics, most recently posted in first" tabindex="0"><table class="index">
643
+ <thead><tr><th scope="col">Topic</th><th scope="col">Category</th><th scope="col">Latest by</th>
644
+ <th scope="col" aria-sort="${order === 'date' ? (down ? 'descending' : 'ascending') : 'none'}">
645
+ <button class="sort" data-act="order" data-order="date">Date ${order === 'date' ? (down ? '▼' : '▲') : '<span class="dim">▽</span>'}</button></th>
646
+ <th scope="col" aria-sort="${order === 'replies' ? (down ? 'descending' : 'ascending') : 'none'}">
647
+ <button class="sort" data-act="order" data-order="replies">Posts ${order === 'replies' ? (down ? '▼' : '▲') : '<span class="dim">▽</span>'}</button></th></tr></thead>
648
+ <tbody>${rows.join('')}</tbody></table>
649
+ ${more ? `<p class="row"><button data-act="more">Show ${Math.min(more, 30)} more of ${more}</button></p>` : ''}</div>`;
650
+ say(`${rows.length} topic${rows.length === 1 ? '' : 's'}${fresh ? `, ${fresh} with something new` : ''}`);
651
+ // The box takes the keyboard only when there is something in it to scroll:
652
+ // a tab stop that does nothing is one more press between a reader and the page.
653
+ const box = $('main').querySelector('.scroll');
654
+ if (box) { if (box.scrollHeight > box.clientHeight) box.tabIndex = 0; else box.removeAttribute('tabindex'); }
655
+ }
656
+
657
+
658
+ async function showTopic(slug, tid, atPost = null) {
659
+ const cat = catBySlug(slug);
660
+ if (!cat) { $('main').innerHTML = '<p class="err">No such category.</p>'; return; }
661
+ const topicId = cat.base + 'ap/topic/' + tid;
662
+ moderators = await read.moderators(cat.base);
663
+ crumbs([['Home', '#/'], [cat.name, `#/c/${slug}`], ['topic', null]]);
664
+ $('main').innerHTML = '<p class="dim">Loading…</p>';
665
+ say('Loading the topic');
666
+ const t = await read.topic(topicId);
667
+ if (!t) { $('main').innerHTML = '<p class="err">No such topic.</p>'; return; }
668
+ crumbs([['Home', '#/'], [cat.name, `#/c/${slug}`], [t.name, null]]);
669
+ const posts = [];
670
+ for (const id of t.posts) posts.push({ id, ...(await read.post(cat.base, id)) });
671
+ const authors = new Map();
672
+ for (const p of posts) {
673
+ if (!p.author || authors.has(p.author)) continue;
674
+ authors.set(p.author, await read.author(cat.base, p.author));
675
+ }
676
+ // Opening a topic reads it, up to the newest post that was in it.
677
+ seen?.markRead(topicId, seen.newest(posts));
678
+ const placed = new Set(t.posts);
679
+ const pending = waiting(topicId).filter(w => !placed.has(w.id));
680
+ $('main').innerHTML = `<div class="topline"><h1>${esc(t.name)}</h1>
681
+ <button class="primary" data-act="newpost" data-topic="${esc(topicId)}">New post</button></div>
682
+ ${topicActs(topicId)}
683
+
684
+ ${(await threaded(posts, authors, cat)).join('')}
685
+ ${pending.map(w => card({ author: w.author, published: w.at, content: toHtml(w.text) },
686
+ { cat, extra: ' waiting', waiting: true })).join('')}`;
687
+ replyBox({ cat, title: 'Reply', inReplyToUrl: t.posts[t.posts.length - 1] || null, topicId });
688
+ $('reply-row').hidden = true; // the thread's own button opens it
689
+ // A closed topic takes nothing more: the forum would refuse it anyway, so
690
+ // the page does not offer it.
691
+ if (t.closed) {
692
+ for (const b of document.querySelectorAll('[data-act="newpost"], [data-act="reply"]')) b.remove();
693
+ const said = document.createElement('p');
694
+ said.className = 'hint';
695
+ said.textContent = 'This topic is closed. No more replies are being taken.';
696
+ document.querySelector('.topline')?.after(said);
697
+ }
698
+ // Arriving from the front page: the post that was linked to, in view and
699
+ // marked, rather than the top of a thread it sits somewhere inside.
700
+ say(`${t.name}, ${posts.length} post${posts.length === 1 ? '' : 's'}`);
701
+ if (atPost) {
702
+ const el = document.getElementById('p-' + atPost);
703
+ // Taken to, not just scrolled to: a keyboard and a screen reader both land
704
+ // on the post that was linked, rather than at the top of the thread.
705
+ if (el) { el.scrollIntoView({ block: 'center' }); el.classList.add('picked'); el.tabIndex = -1; el.focus(); }
706
+ }
707
+ }
708
+
709
+ // The reply box: signed out, it offers the two ways in; signed in, it posts.
710
+ let replyCtx = null;
711
+ function replyBox(ctx) {
712
+ replyCtx = ctx;
713
+ $('reply-row').hidden = false;
714
+ $('reply-open').textContent = ctx.title;
715
+ $('reply-title').textContent = ctx.title;
716
+ $('reply-err').textContent = '';
717
+ const acct = account();
718
+ $('reply-signed-out').hidden = !!acct;
719
+ $('reply-signed-in').hidden = !acct;
720
+ $('fedi-note').textContent = '';
721
+ editing = null;
722
+ $('reply-send').textContent = 'Post reply';
723
+ $('topic-title-row').hidden = !!ctx.topicId;
724
+ $('reply-text').placeholder = ctx.topicId ? 'Write your reply' : 'Your opening post';
725
+ $('reply-text-label').textContent = ctx.topicId ? 'Your reply' : 'Your opening post';
726
+ if (acct) $('reply-as').textContent = acct.handle;
727
+ }
728
+
729
+ // From a handle to a way in. A server that speaks the Mastodon API signs the
730
+ // reader in here; a Lemmy server takes part by its own community address; any
731
+ // other account posts from where it is, naming the category. A bare WebID —
732
+ // typed, not a handle — signs in with no Fediverse account at all: the post
733
+ // shows on this site, but reaches nobody's Mastodon feed, because nothing
734
+ // vouches for its author out there. Nobody is signed up for a Fediverse
735
+ // account behind their back to avoid that; it is the deliberate trade of
736
+ // posting from a WebID alone.
737
+ async function signIn(handleInput, noteId = 'fedi-note') {
738
+ const typed = String(handleInput || '').trim();
739
+ if (/^https?:\/\//u.test(typed)) {
740
+ $(noteId).textContent = 'Asking your pod…';
741
+ try {
742
+ const { authorizationUrl, storageRoot } = await pod.signInWithWebId(typed, location.origin + location.pathname);
743
+ sessionStorage.setItem('bb:return', location.hash);
744
+ sessionStorage.setItem('bb:pod', JSON.stringify({ handle: authorLabel(typed), actor: typed, podHome: storageRoot }));
745
+ location.href = authorizationUrl;
746
+ } catch (e) { $(noteId).textContent = e.message; }
747
+ return;
748
+ }
749
+ const host = hostOfHandle(handleInput);
750
+ if (!host) throw new Error('a handle looks like @you@your.server');
751
+ const at = replyCtx?.cat ? handleOf(replyCtx.cat) : 'the category';
752
+ $(noteId).textContent = `Asking ${host}…`;
753
+ const handle = String(handleInput).trim().replace(/^@/u, '').split('@')[0];
754
+ const said = await serverKind(host, front, undefined, handle);
755
+ const kind = said.kind;
756
+ if (kind === 'mastodon-api') {
757
+ sessionStorage.setItem('bb:return', location.hash);
758
+ location.href = await login.begin(host);
759
+ return;
760
+ }
761
+ const note = $(noteId);
762
+ note.textContent = '';
763
+ if (kind === 'fedipod') {
764
+ if (!said.issuer || !said.actor) throw new Error(`${host} did not say where ${handleInput} signs in`);
765
+ sessionStorage.setItem('bb:return', location.hash);
766
+ sessionStorage.setItem('bb:pod', JSON.stringify({ handle: `@${handle}@${host}`, actor: said.actor, podHome: said.podHome }));
767
+ location.href = await pod.signIn({ issuer: said.issuer, redirectUri: location.origin + location.pathname });
768
+ return;
769
+ }
770
+ note.textContent = kind === 'lemmy'
771
+ ? `From ${host}, subscribe to !${at.replace(/^@/u, '')} and post there; it arrives here.`
772
+ : kind === 'invalid'
773
+ ? 'A handle looks like @you@your.server.'
774
+ : `${host} does not offer a sign-in this page can use. Post from your account there, naming ${at}, and it arrives here.`;
775
+ }
776
+
777
+ // A box in front of the page is movable: press its heading and drag. It
778
+ // stays where it was put until it is closed, and it cannot be dragged off
779
+ // the edge and lost.
780
+ function movable(dlg) {
781
+ const grip = dlg.querySelector('h2');
782
+ if (!grip || grip.dataset.movable) return;
783
+ grip.dataset.movable = '1';
784
+ grip.addEventListener('pointerdown', (e) => {
785
+ if (e.button && e.button !== 0) return;
786
+ const box = dlg.getBoundingClientRect();
787
+ const from = { x: e.clientX - box.left, y: e.clientY - box.top };
788
+ grip.setPointerCapture(e.pointerId);
789
+ const move = (ev) => {
790
+ const x = Math.min(Math.max(0, ev.clientX - from.x), window.innerWidth - box.width);
791
+ const y = Math.min(Math.max(0, ev.clientY - from.y), window.innerHeight - 40);
792
+ dlg.classList.add('moved');
793
+ dlg.style.left = `${x}px`;
794
+ dlg.style.top = `${y}px`;
795
+ };
796
+ const done = () => { grip.removeEventListener('pointermove', move); grip.removeEventListener('pointerup', done); };
797
+ grip.addEventListener('pointermove', move);
798
+ grip.addEventListener('pointerup', done);
799
+ });
800
+ }
801
+
802
+ // Anything the page opens in front of itself goes through here.
803
+ function openPanel(title, html) {
804
+ $('panel-title').textContent = title;
805
+ $('panel-body').innerHTML = html;
806
+ $('panel-err').textContent = '';
807
+ const dlg = $('panel');
808
+ dlg.classList.remove('moved');
809
+ dlg.style.left = dlg.style.top = '';
810
+ movable(dlg);
811
+ dlg.showModal();
812
+ }
813
+ $('panel-close').addEventListener('click', () => $('panel').close());
814
+
815
+ // A panel is a box in front of the page, and it sits OUTSIDE <main> — so the
816
+ // page's own click handler never saw the buttons in one, and pressing Add did
817
+ // nothing at all. Its buttons are settings acts like any other.
818
+ $('panel').addEventListener('click', (e) => {
819
+ const b = e.target.closest('button[data-act]');
820
+ const act = b?.dataset.act || '';
821
+ if (act.startsWith('set-') || act.startsWith('add-') || act.startsWith('drop-')) return onSettings(b);
822
+ return undefined;
823
+ });
824
+
825
+ $('reply-open').addEventListener('click', () => { openReply({ inReplyToUrl: replyCtx?.inReplyToUrl || null }); });
826
+ $('main').addEventListener('click', async (e) => {
827
+
828
+ // Every button in the page that names an action, wherever it sits: a
829
+ // post's row, a topic's row, or the categories line.
830
+ const b = e.target.closest('button[data-act]');
831
+ if (!b) return;
832
+ const id = b.closest('[data-post]')?.dataset.post;
833
+ const act = b.dataset.act;
834
+ if (act === 'reply') return openReply({ inReplyToUrl: id });
835
+ if (act === 'edit') return openEdit(id);
836
+ if (act === 'delete') return removePost(id);
837
+ if (act === 'share') return share(id, b);
838
+ if (act === 'report') return report(id);
839
+ if (act === 'remove') return modRemove(id);
840
+ const topic = b.dataset.topic || b.closest('[data-topic]')?.dataset.topic;
841
+ if (act === 'more') { shown += 30; return showForum(); }
842
+ if (act === 'order') {
843
+ const col = b.dataset.order;
844
+ if (order === col) down = !down; else { order = col; down = true; }
845
+ shown = 30;
846
+ return showForum();
847
+ }
848
+ // Anything that acts as somebody asks who that is first, and remembers what
849
+ // was pressed so signing in finishes it.
850
+ if (GATED[act] && !account()) { rememberIntent(b); return askToSignIn(GATED[act]); }
851
+ if (act === 'join' || act === 'leave') return joinOrLeave(b.dataset.cat, act === 'join');
852
+ if (act === 'ask-join') return askToJoin(b.dataset.cat);
853
+ if (act === 'admit') return modAdmit(id, b.closest('[data-cat]')?.dataset.cat);
854
+ if (act === 'removepost') return modRemoveFromQueue(b.dataset.post, b.closest('[data-cat]')?.dataset.cat);
855
+ if (act === 'save' || act === 'unsave') return saveOrNot(id, act === 'save');
856
+ if (act.startsWith('vote-')) return voteOn(id, act.slice(5), b.dataset.was || 'none');
857
+ if (act === 'approve' || act === 'refuse') return modHeld(id, b.closest('[data-cat]')?.dataset.cat, act === 'approve');
858
+ if (act === 'ban') return modBan(b.dataset.who, b.closest('[data-cat]')?.dataset.cat);
859
+ if (act === 'open-mods') {
860
+ const named = await Promise.all(moderators.map(m => modLabel(m)));
861
+ return openPanel('Moderators', `<p class="hint">${named.map(esc).join(', ') || 'nobody yet'}</p>
862
+ <div class="row oneline">
863
+ <label for="set-mod">Their handle</label>
864
+ <input type="text" id="set-mod" placeholder="@mei@their.server">
865
+ <button data-act="add-mod">Add</button><button data-act="drop-mod">Remove</button>
866
+ </div>
867
+ <p class="hint">Adding somebody makes them a moderator and lets them read the queue.
868
+ The queue is held under the pod's own access rule, and a rule names a person by the
869
+ WebID of their pod: a Mastodon account has none, so it can moderate and have its
870
+ asks acted on, but it cannot open the queue.</p>`);
871
+ }
872
+ if (act === 'open-members') {
873
+ const cat = cats().find(c => c.id === b.dataset.cat);
874
+ if (!cat) return;
875
+ return openPanel(`Members of ${cat.name}`, `<p class="hint">Naming anyone here closes this category to everyone else. A member is named by their WebID, which is what a pod's own rule can grant.</p>
876
+ <div class="row">
877
+ <label for="set-member">Their WebID</label>
878
+ <input type="text" id="set-member" class="cat-member" data-cat="${esc(cat.id)}" placeholder="https://someone.example/profile/card#me">
879
+ <button data-act="add-member" data-cat="${esc(cat.id)}">Let them in</button>
880
+ <button data-act="drop-member" data-cat="${esc(cat.id)}">Take them out</button>
881
+ </div>`);
882
+ }
883
+ if (act.startsWith('set-') || act.startsWith('add-') || act.startsWith('drop-')) return onSettings(b);
884
+ // Search asks for the room to type in rather than holding it open: the
885
+ // button becomes the box, and an empty box left alone becomes the button.
886
+ if (act === 'search') {
887
+ const box = $('find');
888
+ if (!box) return undefined;
889
+ b.hidden = true;
890
+ box.hidden = false;
891
+ box.focus();
892
+ return undefined;
893
+ }
894
+ if (act === 'post-to') return postToTimeline(b.dataset.to, b.dataset.handle || '');
895
+ if (act === 'newpost') return openReply({ inReplyToUrl: replyCtx?.inReplyToUrl || null });
896
+ if (act === 'newtopic') {
897
+ const cat = catBySlug(b.dataset.slug);
898
+ if (!cat) return;
899
+ replyBox({ cat, title: 'Start a topic', inReplyToUrl: null, topicId: null });
900
+ $('reply-row').hidden = true;
901
+ return openReply({ inReplyToUrl: null });
902
+ }
903
+ if (act === 'rename') return modRename(topic);
904
+ if (act === 'pin' || act === 'unpin') return modPin(topic, act === 'pin');
905
+ if (act === 'sitepin' || act === 'siteunpin') return modPin(topic, act === 'sitepin', true);
906
+ if (act === 'lock' || act === 'unlock') return modLock(topic, act === 'lock');
907
+ if (act === 'droptopic') return modDropTopic(topic);
908
+ });
909
+
910
+ // The dialog takes the focus when it opens and hands it back to whatever
911
+ // opened it when it closes, so a keyboard is never left where the page was.
912
+ let reopener = null;
913
+ // What a private category actually promises the person about to write in it.
914
+ // Said in the box, at the moment it matters, because the word Private on a
915
+ // button is a claim and this is the whole of what is behind it.
916
+ function sayPrivacy() {
917
+ const note = $('reply-private');
918
+ if (!note) return;
919
+ const cat = replyCtx?.cat;
920
+ if (!cat?.private) { note.hidden = true; note.textContent = ''; return; }
921
+ note.hidden = false;
922
+ note.textContent = `${cat.name} is private. This is written into your own pod for its members to read, `
923
+ + 'and goes to nobody else. Your followers are not sent it and cannot read it. '
924
+ + 'Anyone admitted later can read it, and anyone admitted now can copy it.';
925
+ }
926
+
927
+ function showDialog() {
928
+ sayPrivacy();
929
+ reopener = document.activeElement;
930
+ $('reply-dlg').showModal();
931
+ (account() ? $('reply-text') : $('fedi-handle'))?.focus();
932
+ }
933
+ $('reply-dlg').addEventListener('close', () => { reopener?.focus?.(); reopener = null; });
934
+
935
+ // The dialog, armed for what it is about to do.
936
+ function openReply({ inReplyToUrl = null } = {}) {
937
+ movable($('reply-dlg'));
938
+ editing = null;
939
+ if (replyCtx) replyCtx.inReplyToUrl = inReplyToUrl;
940
+ $('reply-title').textContent = replyCtx?.title || 'Reply';
941
+ $('reply-send').textContent = 'Post reply';
942
+ $('reply-err').textContent = '';
943
+ showDialog();
944
+ }
945
+
946
+ let editing = null;
947
+ async function openEdit(id) {
948
+ const p = await read.post(replyCtx.cat.base, id);
949
+ if (!p) { $('reply-err').textContent = 'that post is not readable here'; return; }
950
+ editing = { id, page: p.page || null };
951
+ $('reply-title').textContent = 'Edit your post';
952
+ $('reply-text-label').textContent = 'Your post';
953
+ $('reply-send').textContent = 'Save';
954
+ $('reply-err').textContent = '';
955
+ $('topic-title-row').hidden = true;
956
+ // Back to the words, from the HTML the post is kept as.
957
+ if (p.source) {
958
+ $('reply-text').value = p.source;
959
+ } else {
960
+ // Written before the forum kept the source, or written elsewhere: the
961
+ // words are recovered from the HTML, losing whatever marked them up.
962
+ const d = document.createElement('div');
963
+ d.innerHTML = body(p, replyCtx.cat);
964
+ $('reply-text').value = [...d.querySelectorAll('p')].map(x => x.textContent.trim()).join('\n\n') || d.textContent.trim();
965
+ }
966
+ showDialog();
967
+ }
968
+
969
+ // The address of this post on this site, for pasting anywhere. The thread is
970
+ // what a person wants to open, and the post is named in it.
971
+ async function share(id, btn) {
972
+ const hash = location.hash.startsWith('#/t/') ? location.hash : `#/t/${replyCtx.cat.slug}/${(await topicOf(id)) || ''}`;
973
+ const link = `${location.origin}${location.pathname}${hash}`;
974
+ try { await navigator.clipboard.writeText(link); btn.textContent = 'Copied'; say('Link copied'); }
975
+ catch { window.prompt('Copy this address', link); return; }
976
+ setTimeout(() => { btn.textContent = 'Share'; }, 1500);
977
+ }
978
+
979
+ // Which topic holds a post, when the page is showing a category rather than a
980
+ // thread: the card sits inside the topic's own list item.
981
+ async function topicOf(id) {
982
+ const li = [...document.querySelectorAll('ul.list li')].find(el => el.querySelector(`[data-post="${CSS.escape(id)}"]`));
983
+ const a = li?.querySelector('a[href^="#/t/"]');
984
+ return a ? a.getAttribute('href').split('/').pop() : null;
985
+ }
986
+
987
+ async function report(id) {
988
+ const why = window.prompt('Report this post to the moderators. What is wrong with it?');
989
+ if (why === null) return;
990
+ try {
991
+ const cat = replyCtx.cat;
992
+ const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
993
+ const who = podAcct?.actor || login.account()?.url;
994
+ await pod.report({ actor: who, object: id, category: cat.id, inbox, why });
995
+ alert('Reported. A moderator will see it in the forum\'s queue.');
996
+ } catch (e) { alert(e.message); }
997
+ }
998
+
999
+ async function asksTo(activity, forCat = null) {
1000
+ if (!podAcct) throw new Error('moderating from here needs your pod account');
1001
+ const cat = forCat || replyCtx?.cat;
1002
+ if (!cat) throw new Error('this is not in a category the page knows about — open the topic and try from there');
1003
+ // Which step failed, not only that one did. Three requests go out here — ask
1004
+ // the Gateway where the forum takes mail, write the request on your own pod,
1005
+ // hand it to the forum — and each can fail with the browser's bare "Failed to
1006
+ // fetch", which on its own says nothing about which address would not answer.
1007
+ // The same reason voteOn says it.
1008
+ let step = 'finding where the forum takes mail';
1009
+ try {
1010
+ const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1011
+ if (!inbox) throw new Error('the forum did not say where to send it');
1012
+ // moderate() names its own two requests, so nothing is added over the top.
1013
+ step = 'sending the request';
1014
+ await pod.moderate({ actor: podAcct.actor, podHome: podAcct.podHome, inbox, activity });
1015
+ } catch (e) {
1016
+ throw new Error(`${e.message} — while ${step}`);
1017
+ }
1018
+ alert('Sent to the forum. It takes effect once the forum has checked who asked.');
1019
+ }
1020
+
1021
+ // A post out of its topic: a Delete whose origin is the topic it is leaving.
1022
+ async function modRemove(id) {
1023
+ if (!confirm('Remove this post from the topic?')) return;
1024
+ const topicId = replyCtx.topicId || (replyCtx.cat.base + 'ap/topic/' + (await topicOf(id)));
1025
+ try { await asksTo({ type: 'Delete', object: id, origin: topicId }); } catch (e) { alert(e.message); }
1026
+ }
1027
+
1028
+ // A topic's name belongs to the topic, and only the forum writes it.
1029
+ async function modRename(topicId) {
1030
+ const now = document.querySelector('.topline h1')?.textContent || '';
1031
+ const name = window.prompt('Name for this topic', now);
1032
+ if (name === null || !name.trim()) return;
1033
+ try { await asksTo({ type: 'Update', object: { id: topicId, name: name.trim() } }); } catch (e) { alert(e.message); }
1034
+ }
1035
+
1036
+ // Pinned topics are the category's featured collection.
1037
+ // Closed to further replies. `closed` is AS2's own word for a collection
1038
+ // that takes no more, so a reader on any server can see it too.
1039
+ // Joining is a Follow to the category and leaving undoes it. A category
1040
+ // carries its members' posts and nobody else's, so this is what makes
1041
+ // posting from here work at all.
1042
+ async function joinOrLeave(categoryId, on) {
1043
+ const cat = cats().find(c => c.id === categoryId);
1044
+ if (!cat) return;
1045
+ if (!podAcct) {
1046
+ alert(account()
1047
+ ? 'Joining is sent from your own pod, so it needs a pod account — sign out and sign in with your @you@your-pod handle.'
1048
+ : 'Joining needs an account.');
1049
+ return;
1050
+ }
1051
+ try {
1052
+ const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1053
+ const ok = on
1054
+ ? await pod.join({ actor: podAcct.actor, category: cat.id, inbox })
1055
+ : await pod.leave({ actor: podAcct.actor, category: cat.id, inbox });
1056
+ if (!ok) throw new Error('the forum did not take it');
1057
+ if (on) own().join(cat.id); else own().leave(cat.id);
1058
+ forgetIndex();
1059
+ await showForum();
1060
+ } catch (e) { alert(e.message); }
1061
+ }
1062
+
1063
+ // --- signing in where the reader is, rather than where the form is ---
1064
+ //
1065
+ // Everything below the counts needs an account: voting, joining, saving,
1066
+ // asking to be let into a private category. A reader without one used to see
1067
+ // no button at all for some of these and a bare refusal for others, and the
1068
+ // way in was hidden inside the Reply box. Now the button is there, pressing it
1069
+ // asks who you are, and the thing you pressed happens when you come back.
1070
+ //
1071
+ // Signing in leaves the page — it is somebody else's login — so what you were
1072
+ // doing has to outlive the trip. It goes in sessionStorage beside `bb:return`,
1073
+ // which is how the hash already survives it.
1074
+ const GATED = {
1075
+ 'vote-up': 'vote on a post', 'vote-down': 'vote on a post', 'vote-none': 'take back your vote',
1076
+ join: 'join a category', leave: 'leave a category', 'ask-join': 'ask to join a category',
1077
+ save: 'save a post', unsave: 'remove a saved post',
1078
+ };
1079
+
1080
+ function rememberIntent(b) {
1081
+ const d = b.dataset;
1082
+ sessionStorage.setItem('bb:intent', JSON.stringify({
1083
+ // The SAME places the dispatcher reads them from, or the replay acts on a
1084
+ // different post from the one that was pressed: the id is on the enclosing
1085
+ // `data-post`, never on the button.
1086
+ act: d.act, id: b.closest('[data-post]')?.dataset.post || null,
1087
+ cat: d.cat || b.closest('[data-cat]')?.dataset.cat || null,
1088
+ was: d.was || null,
1089
+ }));
1090
+ }
1091
+
1092
+ // Open the sign-in dialog, saying what it is for. `why` is the plain-English
1093
+ // name of the action from GATED, so the reader is told what signing in buys
1094
+ // them rather than being asked for a handle out of nowhere.
1095
+ function askToSignIn(why) {
1096
+ $('signin-why').textContent = why ? `Sign in to ${why}.` : '';
1097
+ $('signin-note').textContent = '';
1098
+ $('signin-dlg').showModal();
1099
+ $('signin-handle').focus();
1100
+ }
1101
+
1102
+ // Back from the login with something to finish. Runs the same handler the
1103
+ // click would have run, so there is one path through each action and not two.
1104
+ async function replayIntent() {
1105
+ let want = null;
1106
+ try { want = JSON.parse(sessionStorage.getItem('bb:intent') || 'null'); } catch { /* unreadable */ }
1107
+ sessionStorage.removeItem('bb:intent');
1108
+ if (!want || !account()) return;
1109
+ if (String(want.act).startsWith('vote-')) return voteOn(want.id, want.act.slice(5), want.was || 'none');
1110
+ if (want.act === 'join' || want.act === 'leave') return joinOrLeave(want.cat, want.act === 'join');
1111
+ if (want.act === 'ask-join') return askToJoin(want.cat);
1112
+ if (want.act === 'save' || want.act === 'unsave') return saveOrNot(want.id, want.act === 'save');
1113
+ }
1114
+
1115
+ // A vote goes to the category as a Like or a Dislike, and the Undo of
1116
+ // whichever was cast takes it back. The forum counts them; this browser only
1117
+ // remembers which way this reader went, so the buttons can say so.
1118
+ async function voteOn(postId, way, was) {
1119
+ // A vote is signed by the pod that casts it, so a Fediverse-only sign-in
1120
+ // cannot cast one. Said plainly: the reader has just signed in and is being
1121
+ // refused, and "needs your pod account" on its own does not say what to do.
1122
+ if (!podAcct) {
1123
+ alert(account()
1124
+ ? 'A vote is sent from your own pod, so it needs a pod account — sign out and sign in with your @you@your-pod handle.'
1125
+ : 'Voting needs an account.');
1126
+ return;
1127
+ }
1128
+ const cat = replyCtx?.cat || cats().find(c => c.slug === feedFilter) || cats()[0];
1129
+ if (!cat) return;
1130
+ let step = 'finding where the forum takes mail';
1131
+ try {
1132
+ const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1133
+ if (!inbox) throw new Error('the forum did not say where to send it');
1134
+ step = `sending the vote to ${new URL(inbox).host}`;
1135
+ await pod.vote({ actor: podAcct.actor, post: postId, category: cat.id, inbox, way, was });
1136
+ own().vote(postId, way);
1137
+ forgetIndex();
1138
+ paintVote(postId, way, was);
1139
+ say(way === 'none' ? 'Vote taken back.' : 'Voted. The count follows once the forum has taken it.');
1140
+ } catch (e) {
1141
+ // Which step failed, not only that one did: "failed to fetch" on its own
1142
+ // says nothing about which address would not answer.
1143
+ alert(`${e.message} — while ${step}`);
1144
+ }
1145
+ }
1146
+
1147
+ // A post on somebody's timeline: their inbox is asked for, the post is written
1148
+ // in the sender's own public container, and handed over. Each way it can fail
1149
+ // says which address would not answer, since a bare "failed to fetch" names
1150
+ // none of them.
1151
+ async function postToTimeline(to, handle) {
1152
+ const box = $('dm-text');
1153
+ const err = $('dm-err');
1154
+ const text = box?.value.trim();
1155
+ if (!err) return;
1156
+ if (!text) { err.className = 'err'; err.textContent = 'Write something first.'; return; }
1157
+ if (!podAcct) { err.className = 'err'; err.textContent = 'Posting to someone is done from your own pod account.'; return; }
1158
+ err.className = '';
1159
+ err.textContent = 'Posting…';
1160
+ try {
1161
+ // Their handle, for the mention the post carries. Nothing else about them
1162
+ // is needed: the sender's own agent works out where it goes and signs it.
1163
+ const card = await read.actorCard(to).catch(() => null);
1164
+ await pod.postTo({
1165
+ actor: podAcct.actor, podHome: podAcct.podHome, handle: podAcct.handle,
1166
+ to, toHandle: card?.handle || handle || '', front, text,
1167
+ });
1168
+ box.value = '';
1169
+ err.className = 'said';
1170
+ err.textContent = 'Handed to your own account. It goes out signed when your agent next reads its inbox.';
1171
+ say('Posted.');
1172
+ } catch (e) {
1173
+ err.className = 'err';
1174
+ err.textContent = e.message;
1175
+ }
1176
+ }
1177
+
1178
+ // The reader's own vote, on the buttons they just pressed. The forum's count
1179
+ // is the truth and follows when it has taken the vote; until then the page
1180
+ // showed the row exactly as it was, so a vote looked like nothing happening.
1181
+ function paintVote(postId, way, was) {
1182
+ const acts = document.querySelector(`.acts[data-post="${CSS.escape(postId)}"]`);
1183
+ const [upBtn, downBtn] = acts ? acts.querySelectorAll('button[data-act^="vote-"]') : [];
1184
+ if (!upBtn || !downBtn) return;
1185
+ const count = (b) => Number(String(b.textContent).replace(/[^0-9]/gu, '')) || 0;
1186
+ let up = count(upBtn);
1187
+ let down = count(downBtn);
1188
+ if (was === 'up') up -= 1;
1189
+ if (was === 'down') down -= 1;
1190
+ if (way === 'up') up += 1;
1191
+ if (way === 'down') down += 1;
1192
+ const set = (b, its, mark, n, label) => {
1193
+ b.dataset.act = `vote-${way === its ? 'none' : its}`;
1194
+ b.dataset.was = way;
1195
+ b.textContent = `${mark} ${Math.max(0, n)}`;
1196
+ b.classList.toggle('voted', way === its);
1197
+ if (way === its) b.setAttribute('aria-pressed', 'true'); else b.removeAttribute('aria-pressed');
1198
+ b.setAttribute('aria-label', way === its ? `Take back your vote ${label}` : `Vote ${label} this post`);
1199
+ };
1200
+ set(upBtn, 'up', '\u25B2', up, 'for');
1201
+ set(downBtn, 'down', '\u25BC', down, 'against');
1202
+ }
1203
+
1204
+ // Asking to be let into a private category: the same Follow that joining
1205
+ // sends, which a category approving its members holds for a moderator.
1206
+ async function askToJoin(categoryId) {
1207
+ const cat = cats().find(c => c.id === categoryId);
1208
+ if (!cat || !podAcct) return;
1209
+ try {
1210
+ const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1211
+ await pod.join({ actor: podAcct.actor, category: cat.id, inbox });
1212
+ say('Asked. A moderator sees it in the forum\'s queue.');
1213
+ alert('Your request is with the moderators.');
1214
+ } catch (e) { alert(e.message); }
1215
+ }
1216
+
1217
+ // Admitting somebody who asked: their follow is accepted, and in a private
1218
+ // category their pod is granted the reading that membership means.
1219
+ async function modAdmit(who, slug) {
1220
+ forgetIndex(); // whatever this does, the index behind it is now out of date
1221
+ const cat = cats().find(c => c.slug === slug) || replyCtx?.cat;
1222
+ if (!cat || !who) return;
1223
+ try {
1224
+ await asksTo({ type: 'Join', object: who }, cat);
1225
+ await asksTo({ type: 'Add', object: who, target: cat.base + 'ap/members' }, cat);
1226
+ say('Admitted. It takes effect once the forum has checked who asked.');
1227
+ } catch (e) { alert(e.message); }
1228
+ }
1229
+
1230
+ // A bookmark is this reader's own note to come back to, in this browser.
1231
+ function saveOrNot(postId, on) {
1232
+ const m = own();
1233
+ if (on) {
1234
+ const card = document.querySelector(`[data-post="${CSS.escape(postId)}"]`)?.closest('article');
1235
+ m.save({ id: postId, at: new Date().toISOString(), topic: replyCtx?.topicId || null,
1236
+ cat: replyCtx?.cat?.slug || null, text: (card?.querySelector('.body')?.textContent || '').trim().slice(0, 140) });
1237
+ } else m.unsave(postId);
1238
+ route();
1239
+ }
1240
+
1241
+ // A held post: let through, or turned away. Both are asks like any other,
1242
+ // published at the moderator's own pod and checked there.
1243
+ async function modHeld(postId, slug, through) {
1244
+ forgetIndex(); // whatever this does, the index behind it is now out of date
1245
+ const cat = cats().find(c => c.slug === slug) || replyCtx?.cat;
1246
+ if (!cat) { alert('which category is that in?'); return; }
1247
+ try {
1248
+ await asksTo({ type: through ? 'Accept' : 'Reject', object: postId }, cat);
1249
+ say(through ? 'Let through. It is carried once the forum has checked who asked.' : 'Turned away.');
1250
+ } catch (e) { alert(e.message); }
1251
+ }
1252
+
1253
+ // A reported post taken out of its topic, from the queue rather than from
1254
+ // the thread: the topic it is in is the one the forum has it in.
1255
+ async function modRemoveFromQueue(postId, slug) {
1256
+ forgetIndex(); // whatever this does, the index behind it is now out of date
1257
+ const cat = cats().find(c => c.slug === slug);
1258
+ if (!cat || !postId) return;
1259
+ if (!confirm('Remove this post from its topic?')) return;
1260
+ try {
1261
+ const copy = await read.post(cat.base, postId);
1262
+ if (!copy?.topic) throw new Error('the forum does not say which topic holds it');
1263
+ await asksTo({ type: 'Delete', object: postId, origin: copy.topic }, cat);
1264
+ say('Asked. It takes effect once the forum has checked who asked.');
1265
+ } catch (e) { alert(e.message); }
1266
+ }
1267
+
1268
+ // Banning: the category blocks them, which is the whole of what a group can
1269
+ // enforce — it stops carrying them and ends the following.
1270
+ async function modBan(who, slug) {
1271
+ const cat = cats().find(c => c.slug === slug) || replyCtx?.cat;
1272
+ if (!cat || !who) return;
1273
+ if (!confirm(`Ban ${authorLabel(who)} from ${cat.name}? Their posts stop being carried.`)) return;
1274
+ try {
1275
+ await asksTo({ type: 'Block', object: who }, cat);
1276
+ say('Asked. It takes effect once the forum has checked who asked.');
1277
+ } catch (e) { alert(e.message); }
1278
+ }
1279
+
1280
+ // The line naming who moderates, from actor ids: each one's own handle where
1281
+ // something says it, and the address it was read from otherwise.
1282
+ async function paintMods(ids) {
1283
+ if (!ids.length) return;
1284
+ const cat = cats()[0];
1285
+ const named = await Promise.all(ids.map(async (id) => {
1286
+ const card = cat ? await authorOnce(cat.base, id).catch(() => null) : null;
1287
+ const handle = card?.handle || await modLabel(id);
1288
+ return `<a href="#/who/${encodeURIComponent(id)}">${esc(handle)}</a>`;
1289
+ }));
1290
+ $('mods-who').innerHTML = `<span class="lead">moderators:</span> ${named.join(', ')}`;
1291
+ }
1292
+
1293
+ // What to call a moderator: the card the forum kept of them, then the actor's
1294
+ // own word for it, and a name read off the address only when neither answers.
1295
+ async function modLabel(actorId) {
1296
+ const cat = cats()[0];
1297
+ const card = cat ? await authorOnce(cat.base, actorId) : null;
1298
+ if (card?.handle) return card.handle;
1299
+ return (await read.actorHandle(actorId).catch(() => null)) || authorLabel(actorId);
1300
+ }
1301
+
1302
+ // What a moderator types is a handle; what the wire names is the actor behind
1303
+ // it. A server that will not say who that is stops the ask here, where the
1304
+ // person who typed it is looking, rather than on the forum an hour later.
1305
+ async function actorFor(typed) {
1306
+ if (!typed) return null;
1307
+ try { return await actorOfHandle(typed); } catch (e) { settingsSaid(e.message, true); return null; }
1308
+ }
1309
+
1310
+ // Every settings change is an ordinary activity naming what it changes: a
1311
+ // Create of a Group for a new category, an Update for a name, an Add or a
1312
+ // Remove for who moderates and who may read.
1313
+ // What came of a settings change, where the reader can see it: the line that
1314
+ // reads aloud is invisible on the screen, so a change that worked looked
1315
+ // exactly like a button that did nothing.
1316
+ function settingsSaid(text, bad = false) {
1317
+ say(text);
1318
+ if ($('panel').open) {
1319
+ const box = $('panel-err');
1320
+ box.textContent = text;
1321
+ box.className = bad ? 'err' : 'said';
1322
+ return;
1323
+ }
1324
+ alert(text);
1325
+ }
1326
+
1327
+ async function settingsAsk(...activities) {
1328
+ try {
1329
+ const inbox = await pod.podInboxOf(cats()[0]?.id || forum.id, { front, handle: cats()[0]?.slug || forum.handle });
1330
+ for (const activity of activities) {
1331
+ await pod.moderate({ actor: podAcct.actor, podHome: podAcct.podHome, inbox, activity });
1332
+ }
1333
+ settingsSaid('Sent. It takes effect once the forum has checked who asked.');
1334
+ } catch (e) { settingsSaid(e.message, true); }
1335
+ }
1336
+
1337
+ async function onSettings(b) {
1338
+ const act = b.dataset.act;
1339
+ const catId = b.dataset.cat;
1340
+ const val = (sel) => document.querySelector(sel)?.value.trim() || '';
1341
+ if (act === 'set-forum-name') {
1342
+ const name = val('#set-forum-name');
1343
+ if (name) await settingsAsk({ type: 'Update', object: { id: forum.id, name } });
1344
+ } else if (act === 'set-cat-name') {
1345
+ const name = document.querySelector(`.cat-name[data-cat="${CSS.escape(catId)}"]`)?.value.trim();
1346
+ if (name) await settingsAsk({ type: 'Update', object: { id: catId, name } });
1347
+ } else if (act === 'add-cat') {
1348
+ const slug = val('#set-new-slug').toLowerCase();
1349
+ const name = val('#set-new-name') || slug;
1350
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/u.test(slug)) { alert('a Fediverse username is lower-case letters, digits and hyphens'); return; }
1351
+ const priv = document.querySelector('input[name="set-new-private"]:checked')?.value === 'private';
1352
+ await settingsAsk({ type: 'Create', target: forum.id,
1353
+ object: { type: 'Group', preferredUsername: slug, name, ...(priv ? { manuallyApprovesFollowers: true } : {}) } });
1354
+ } else if (act === 'add-member' || act === 'drop-member') {
1355
+ const webid = document.querySelector(`.cat-member[data-cat="${CSS.escape(catId)}"]`)?.value.trim();
1356
+ const cat = cats().find(c => c.id === catId);
1357
+ if (webid && cat) await settingsAsk({ type: act === 'add-member' ? 'Add' : 'Remove', object: webid, target: cat.base + 'ap/members' });
1358
+ } else if (act === 'add-mod' || act === 'drop-mod') {
1359
+ const typed = val('#set-mod');
1360
+ if (!typed) { settingsSaid('Type a handle first, like @mei@their.server.', true); return; }
1361
+ const who = await actorFor(typed);
1362
+ if (!who) return; // actorFor has already said why
1363
+ // One handle says two things: who may ask for moderation, and whose pod
1364
+ // the rule on the queue names. The forum finds the pod behind the actor.
1365
+ // Moderators are the forum's, so the ask names the forum's list; the
1366
+ // forum writes every category's from it.
1367
+ const type = act === 'add-mod' ? 'Add' : 'Remove';
1368
+ await settingsAsk({ type, object: who, target: `${base}ap/administrators` },
1369
+ { type, object: who, target: `${base}mod/` });
1370
+ }
1371
+ }
1372
+
1373
+ async function modLock(topicId, on) {
1374
+ // AS2 types `closed` as a TIME, so closing carries the moment it closed and
1375
+ // reopening REMOVES it — which ActivityPub spells as `null` (§6.3.1, partial
1376
+ // updates). It used to send `closed: false`, which is neither a time nor a
1377
+ // removal: it arrived as the text "false" typed as a date, and `!!"false"` is
1378
+ // true, so every reopen closed the topic again.
1379
+ //
1380
+ // The type is named because the reading is built from a graph, and a node
1381
+ // with nothing on it but an id collapses to a bare id — taking the removal
1382
+ // with it. A topic IS an OrderedCollection, which is what the forum publishes.
1383
+ try {
1384
+ await asksTo({ type: 'Update',
1385
+ object: { id: topicId, type: 'OrderedCollection', closed: on ? new Date().toISOString() : null } });
1386
+ } catch (e) { alert(e.message); }
1387
+ }
1388
+
1389
+ async function modPin(topicId, on, wholeSite = false) {
1390
+ // Which featured collection it goes into says how far the pin reaches: the
1391
+ // category's, or the forum's own.
1392
+ const target = wholeSite ? base + 'ap/featured' : replyCtx.cat.base + 'ap/featured';
1393
+ try { await asksTo({ type: on ? 'Add' : 'Remove', object: topicId, target }); } catch (e) { alert(e.message); }
1394
+ }
1395
+
1396
+ // A topic removed from the category is a topic gone (FEP-f15d).
1397
+ async function modDropTopic(topicId) {
1398
+ if (!confirm('Delete this topic and every post in it?')) return;
1399
+ try { await asksTo({ type: 'Remove', object: topicId, target: replyCtx.cat.id }); } catch (e) { alert(e.message); }
1400
+ }
1401
+
1402
+ async function removePost(id) {
1403
+ if (!confirm('Delete this post? Everywhere it has reached is told to remove it.')) return;
1404
+ try {
1405
+ const cat = replyCtx.cat;
1406
+ if (podAcct) {
1407
+ const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1408
+ await pod.remove({ actor: podAcct.actor, podHome: podAcct.podHome, id, category: cat.id,
1409
+ categoryBase: cat.base, isPrivate: !!cat.private, inbox });
1410
+ } else {
1411
+ const p = await read.post(cat.base, id);
1412
+ await login.remove({ url: p?.page || id });
1413
+ }
1414
+ forgetIndex();
1415
+ await route();
1416
+ } catch (e) { alert(e.message); }
1417
+ }
1418
+ $('reply-cancel').addEventListener('click', () => $('reply-dlg').close());
1419
+ // Open or private is the forum's setting, changed here and nowhere else:
1420
+ // nobody joining, and nobody being named a member, ever moves a category
1421
+ // between the two.
1422
+ $('main').addEventListener('change', async (e) => {
1423
+ const r = e.target;
1424
+ if (r?.dataset?.act !== 'set-cat-private' || !r.checked) return;
1425
+ const cat = cats().find(c => c.id === r.dataset.cat);
1426
+ if (!cat) return;
1427
+ const priv = r.value === 'private';
1428
+ if (priv === !!cat.private) return;
1429
+ await settingsAsk({ type: 'Update', object: { id: cat.id, manuallyApprovesFollowers: priv } });
1430
+ cat.private = priv;
1431
+ });
1432
+
1433
+ let findTimer = null;
1434
+ function onFind(e) {
1435
+ if (e.target?.id !== 'find') return;
1436
+ finding = e.target.value;
1437
+ clearTimeout(findTimer);
1438
+ findTimer = setTimeout(async () => {
1439
+ const where = e.target.selectionStart;
1440
+ await showForum();
1441
+ const box = document.getElementById('find');
1442
+ if (box) { box.focus(); box.setSelectionRange(where, where); }
1443
+ }, 250);
1444
+ }
1445
+ $('main').addEventListener('input', onFind);
1446
+
1447
+ // The moderators row is above <main>, so the page's own handlers never see the
1448
+ // controls at its end: they are the same acts, listened for here.
1449
+ $('mods-line').addEventListener('click', (e) => {
1450
+ const b = e.target.closest('button[data-act]');
1451
+ if (!b) return;
1452
+ if (b.dataset.act === 'search') {
1453
+ const box = $('find');
1454
+ if (!box) return;
1455
+ b.hidden = true;
1456
+ box.hidden = false;
1457
+ box.focus();
1458
+ return;
1459
+ }
1460
+ if (b.dataset.act === 'newtopic') {
1461
+ const cat = catBySlug(b.dataset.slug);
1462
+ if (!cat) return;
1463
+ replyBox({ cat, title: 'Start a topic', inReplyToUrl: null, topicId: null });
1464
+ $('reply-row').hidden = true;
1465
+ openReply({ inReplyToUrl: null });
1466
+ }
1467
+ });
1468
+ $('mods-line').addEventListener('input', onFind);
1469
+ $('mods-line').addEventListener('focusout', onFindOut);
1470
+
1471
+ // Nothing typed and the reader has gone elsewhere: the row takes its width
1472
+ // back. Something typed stays open, so what is being searched for is visible.
1473
+ function onFindOut(e) {
1474
+ if (e.target?.id !== 'find' || e.target.value.trim()) return;
1475
+ const button = document.querySelector('button[data-act="search"]');
1476
+ if (!button) return;
1477
+ e.target.hidden = true;
1478
+ button.hidden = false;
1479
+ }
1480
+ $('main').addEventListener('focusout', onFindOut);
1481
+
1482
+ $('reply-image').addEventListener('change', async (e) => {
1483
+ const file = e.target.files?.[0];
1484
+ const said = $('reply-image-said');
1485
+ if (!file) return;
1486
+ if (!podAcct) { said.textContent = 'adding an image needs your pod account'; e.target.value = ''; return; }
1487
+ said.textContent = 'putting it in your pod…';
1488
+ try {
1489
+ const url = await pod.upload({ actor: podAcct.actor, podHome: podAcct.podHome, file });
1490
+ const box = $('reply-text');
1491
+ const alt = file.name.replace(/\.[^.]+$/u, '').replace(/[-_]+/gu, ' ');
1492
+ box.value = `${box.value}${box.value && !box.value.endsWith('\n') ? '\n\n' : ''}![${alt}](${url})\n`;
1493
+ said.textContent = 'added';
1494
+ box.focus();
1495
+ } catch (err) { said.textContent = err.message; }
1496
+ e.target.value = '';
1497
+ });
1498
+
1499
+ $('fedi-login').addEventListener('click', async () => {
1500
+ $('reply-err').textContent = '';
1501
+ $('fedi-login').disabled = true;
1502
+ try { await signIn($('fedi-handle').value); } catch (e) { $('reply-err').textContent = e.message; }
1503
+ $('fedi-login').disabled = false;
1504
+ });
1505
+ $('fedi-handle').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('fedi-login').click(); });
1506
+
1507
+ // The stand-alone sign-in. Same signIn() as the Reply box's, reporting into
1508
+ // this dialog's own line rather than the other one's.
1509
+ $('signin-go').addEventListener('click', async () => {
1510
+ $('signin-note').textContent = '';
1511
+ $('signin-go').disabled = true;
1512
+ try { await signIn($('signin-handle').value, 'signin-note'); } catch (e) { $('signin-note').textContent = e.message; }
1513
+ $('signin-go').disabled = false;
1514
+ });
1515
+ $('signin-handle').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('signin-go').click(); });
1516
+ // Cancelling drops what was remembered: the reader said no, and it should not
1517
+ // happen to them the next time they sign in for something else.
1518
+ $('signin-cancel').addEventListener('click', () => { sessionStorage.removeItem('bb:intent'); $('signin-dlg').close(); });
1519
+ $('signin-dlg').addEventListener('cancel', () => sessionStorage.removeItem('bb:intent'));
1520
+ async function signOutOfEverything() {
1521
+ if (podAcct) { await pod.signOut(); podAcct = null; localStorage.removeItem('bb:acct'); sessionStorage.removeItem('bb:pod'); } else login.signOut();
1522
+ if (replyCtx) replyBox(replyCtx);
1523
+ paintWho();
1524
+ }
1525
+ $('masto-logout').addEventListener('click', signOutOfEverything);
1526
+
1527
+ $('who-act').addEventListener('click', async () => {
1528
+ if (!account()) return askToSignIn('take part, and to moderate if you are a moderator');
1529
+ await signOutOfEverything();
1530
+ say('Signed out.');
1531
+ route();
1532
+ });
1533
+
1534
+ async function saveEdit({ body }) {
1535
+ const cat = replyCtx.cat;
1536
+ if (podAcct) {
1537
+ const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1538
+ return pod.edit({ actor: podAcct.actor, podHome: podAcct.podHome, id: editing.id, text: body, category: cat.id,
1539
+ categoryBase: cat.base, isPrivate: !!cat.private, inbox });
1540
+ }
1541
+ return login.edit({ url: editing.page || editing.id, text: body });
1542
+ }
1543
+
1544
+ // A post written into the reader's own pod and announced to the forum. The
1545
+ // category is named on its own pod, which is where a delivery is taken.
1546
+ async function postFromPod({ topic, body }) {
1547
+ const cat = replyCtx.cat;
1548
+ const inbox = await pod.podInboxOf(cat.id, { front, handle: cat.slug || null });
1549
+ if (!inbox) throw new Error('the forum did not say where to send it');
1550
+ // Posting in an open category joins it on the way past, which is what makes
1551
+ // the post carry. A private one is joined by asking and being admitted, so
1552
+ // the post is refused here instead, and says so.
1553
+ if (!cat.private) await pod.join({ actor: podAcct.actor, category: cat.id, inbox });
1554
+ return pod.post({
1555
+ actor: podAcct.actor, podHome: podAcct.podHome, category: cat.id, categoryBase: cat.base,
1556
+ categoryHandle: handleOf(cat), isPrivate: !!cat.private, inbox,
1557
+ topic, text: body, inReplyTo: replyCtx.inReplyToUrl,
1558
+ context: replyCtx.topicId ? replyCtx.topicId : null,
1559
+ });
1560
+ }
1561
+ $('reply-send').addEventListener('click', async () => {
1562
+ if (!replyCtx) return;
1563
+ const body = $('reply-text').value.trim();
1564
+ if (!body) return;
1565
+ // A Mastodon post has no topic name to give: its first line stands in for
1566
+ // one, which is all that server can say. A pod post names the topic on the
1567
+ // activity that opens it, and its words are only its words.
1568
+ // The topic's name, asked for only when a topic is being opened; the
1569
+ // post's own title, which any post may have and any edit may change.
1570
+ const topic = !editing && !replyCtx.topicId ? $('topic-title').value.trim() : '';
1571
+
1572
+ const text = topic ? `${topic}\n\n${body}` : body;
1573
+ $('reply-err').textContent = '';
1574
+ $('reply-send').disabled = true;
1575
+ try {
1576
+ const made = editing
1577
+ ? await saveEdit({ body })
1578
+ : podAcct
1579
+ ? await postFromPod({ topic, body })
1580
+ : await login.post({ text, mention: handleOf(replyCtx.cat), inReplyToUrl: replyCtx.inReplyToUrl });
1581
+ const acct = account();
1582
+ if (!editing && replyCtx.topicId) remember(replyCtx.topicId, { id: made.uri || made.url, author: acct.url || acct.handle, text, at: new Date().toISOString() });
1583
+ $('reply-text').value = '';
1584
+ $('topic-title').value = '';
1585
+ $('reply-dlg').close();
1586
+ forgetIndex();
1587
+ await route();
1588
+ } catch (e) { $('reply-err').textContent = e.message; }
1589
+ $('reply-send').disabled = false;
1590
+ });
1591
+
1592
+ // Back from the reader's server with a code: finish the sign-in, then clean
1593
+ // the address so a reload does not replay it.
1594
+ (async () => {
1595
+ // Back from a pod's sign-in: the session is this browser's, and the handle
1596
+ // it belongs to was put aside before leaving.
1597
+ // A reader who signed in stays signed in: the pod session lives in this
1598
+ // browser, and the handle it belongs to is kept beside it.
1599
+ const kept = localStorage.getItem('bb:acct');
1600
+ if (kept && !sessionStorage.getItem('bb:pod')) {
1601
+ try {
1602
+ const s = await pod.session();
1603
+ if (s) { podAcct = { ...JSON.parse(kept), webId: s.webId }; read = reader({ session: s }); }
1604
+ else localStorage.removeItem('bb:acct');
1605
+ } catch { localStorage.removeItem('bb:acct'); }
1606
+ }
1607
+ const waiting = sessionStorage.getItem('bb:pod');
1608
+ if (waiting) {
1609
+ try {
1610
+ const who = JSON.parse(waiting);
1611
+ const s = params.get('code') ? await pod.complete(location.href) : await pod.session();
1612
+ if (s) {
1613
+ podAcct = { ...who, webId: s.webId };
1614
+ read = reader({ session: s });
1615
+ try { localStorage.setItem('bb:acct', JSON.stringify(who)); } catch { /* blocked storage */ }
1616
+ sessionStorage.removeItem('bb:pod');
1617
+ params.delete('code'); params.delete('state'); params.delete('iss');
1618
+ const q = String(params);
1619
+ const back = sessionStorage.getItem('bb:return') || '';
1620
+ sessionStorage.removeItem('bb:return');
1621
+ history.replaceState(null, '', `${location.pathname}${q ? '?' + q : ''}${back}`);
1622
+ }
1623
+ } catch (e) { $('reply-err').textContent = e.message; }
1624
+ }
1625
+ const code = params.get('code');
1626
+ const state = params.get('state');
1627
+ if (code && state) {
1628
+ try {
1629
+ await login.complete({ state, code });
1630
+ params.delete('code'); params.delete('state');
1631
+ const back = sessionStorage.getItem('bb:return') || '';
1632
+ sessionStorage.removeItem('bb:return');
1633
+ const q = String(params);
1634
+ history.replaceState(null, '', `${location.pathname}${q ? '?' + q : ''}${back}`);
1635
+ } catch (e) { $('reply-err').textContent = e.message; }
1636
+ }
1637
+ // Sign-up lives at the Gateway, which is this page's own site or the one it is a face of.
1638
+ $('fedipod-signup').href = front + '/new-account';
1639
+ window.addEventListener('hashchange', () => route());
1640
+ await load();
1641
+ // Whatever the reader pressed before being asked to sign in. After `load`,
1642
+ // because voting and joining both need the categories to be known.
1643
+ await replayIntent();
1644
+ })();