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/README.md +195 -0
- package/bin/fedipod-bb.mjs +113 -0
- package/fep-draft.md +110 -0
- package/package.json +26 -0
- package/site/bb.js +1644 -0
- package/site/index.html +301 -0
- package/site/markdown.mjs +0 -0
- package/site/masto.mjs +179 -0
- package/site/mine.mjs +48 -0
- package/site/oidc-session.mjs +6 -0
- package/site/pod.mjs +394 -0
- package/site/private.mjs +98 -0
- package/site/read.mjs +294 -0
- package/site/seen.mjs +64 -0
- package/src/access.mjs +79 -0
- package/src/credential.mjs +37 -0
- package/src/forum-agent.mjs +991 -0
- package/src/forum-intake.mjs +112 -0
- package/src/index.mjs +9 -0
- package/src/moderation.mjs +303 -0
- package/src/provision.mjs +37 -0
- package/src/publish.mjs +244 -0
- package/src/run.mjs +52 -0
- package/src/settings.mjs +186 -0
- package/src/topics.mjs +165 -0
- package/src/urls.mjs +90 -0
- package/src/wire.mjs +114 -0
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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)}/<forum>/</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' : ''}\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
|
+
})();
|