fedipod-bb 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +3 -2
- package/site/bb.js +235 -351
- package/site/draw.mjs +370 -0
- package/site/index.html +2 -2
- package/site/pod.mjs +17 -0
- package/site/read.mjs +67 -18
- package/src/forum-agent.mjs +62 -4
- package/src/gateway.mjs +19 -0
- package/src/render.mjs +135 -0
package/site/bb.js
CHANGED
|
@@ -7,22 +7,26 @@
|
|
|
7
7
|
//
|
|
8
8
|
// /bb/?forum=<handle> a forum attached to this site
|
|
9
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
|
-
//
|
|
10
|
+
// https://bb.<site>/<handle>/ the same forum at its own address, where
|
|
11
|
+
// <handle>/c/<slug> and <handle>/t/<slug>/<tid> are a category and a topic
|
|
12
|
+
// #/ #/c/<slug> #/t/<slug>/<tid> the same views anywhere else
|
|
13
|
+
//
|
|
14
|
+
// At its own host a forum's page may arrive drawn, with what it was drawn
|
|
15
|
+
// from beside it (src/render.mjs); the page then reads none of that again.
|
|
12
16
|
|
|
13
|
-
import { reader, placeOf, authorLabel, cacheKey, categoryBase, cleanHtml } from './read.mjs';
|
|
17
|
+
import { reader, placeOf, viewOfPath, authorLabel, cacheKey, categoryBase, cleanHtml, sentNotListed } from './read.mjs';
|
|
14
18
|
import { MastoLogin, hostOfHandle, serverKind, actorOfHandle, returnAddress } from './masto.mjs';
|
|
15
19
|
import * as pod from './pod.mjs';
|
|
16
20
|
import { mine as keptByReader } from './mine.mjs';
|
|
17
21
|
import { readState } from './seen.mjs';
|
|
18
22
|
import { toHtml } from './markdown.mjs';
|
|
23
|
+
import * as draw from './draw.mjs';
|
|
19
24
|
|
|
25
|
+
const { esc, when } = draw;
|
|
20
26
|
const $ = (id) => document.getElementById(id);
|
|
21
27
|
// Said to a screen reader when the page changes under it and no focus moves:
|
|
22
28
|
// what is loading, how much arrived, that a link was copied.
|
|
23
29
|
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
30
|
const ago = (iso) => {
|
|
27
31
|
const ms = Date.now() - Date.parse(iso);
|
|
28
32
|
if (!Number.isFinite(ms)) return '';
|
|
@@ -38,6 +42,15 @@ const params = new URLSearchParams(location.search);
|
|
|
38
42
|
const place = placeOf({ origin: location.origin, pathname: location.pathname, search: location.search });
|
|
39
43
|
const { front, base } = place;
|
|
40
44
|
const frontHost = new URL(front).host;
|
|
45
|
+
const addr = draw.addresses(place.root);
|
|
46
|
+
// Where a sign-in comes back to: the forum's own address at its own host,
|
|
47
|
+
// whichever of its pages it started from, so a reader's server is asked to
|
|
48
|
+
// approve one address and not one per topic. Where the reader was is kept
|
|
49
|
+
// beside it (`bb:return`) and gone back to after.
|
|
50
|
+
const signInAddress = place.root ? location.origin + place.root : location.origin + location.pathname;
|
|
51
|
+
const whereNow = () => (place.root ? location.pathname + location.hash : location.hash);
|
|
52
|
+
// What the page arrived drawn from, when it was drawn at the forum's host.
|
|
53
|
+
const boot = (() => { try { return JSON.parse($('bb-data')?.textContent || 'null'); } catch { return null; } })();
|
|
41
54
|
// The reader asks anonymously first; where a category is members-only it
|
|
42
55
|
// asks again with the pod session, if there is one. Right after this reader
|
|
43
56
|
// posts, the next read goes past the browser's own copy of each document (a
|
|
@@ -47,7 +60,8 @@ let freshRead = false;
|
|
|
47
60
|
const readerFetch = (url, init = {}) => fetch(url, freshRead ? { ...init, cache: 'reload' } : init);
|
|
48
61
|
const makeReader = (session = null) => reader({ session, fetch: readerFetch });
|
|
49
62
|
let read = makeReader();
|
|
50
|
-
const login = new MastoLogin({ storage: localStorage,
|
|
63
|
+
const login = new MastoLogin({ storage: localStorage,
|
|
64
|
+
redirectUri: returnAddress(place.root ? signInAddress + location.search : location.href) });
|
|
51
65
|
// What this reader has already read. Theirs, in their own browser: a public
|
|
52
66
|
// forum has nowhere to keep it and no business keeping it.
|
|
53
67
|
const seen = base ? readState({ storage: localStorage, forum: base }) : null;
|
|
@@ -67,47 +81,6 @@ const handleOf = (cat) => {
|
|
|
67
81
|
if (place.handle && cat.slug) return `@${cat.slug}@${frontHost}`;
|
|
68
82
|
return cat.id;
|
|
69
83
|
};
|
|
70
|
-
// A post names the category it is for, and every reader of this page is
|
|
71
|
-
// already in it: the address is how it got here, not something to read.
|
|
72
|
-
function body(p, cat) {
|
|
73
|
-
const html = cleanHtml(p?.content);
|
|
74
|
-
if (!cat) return html;
|
|
75
|
-
const d = document.createElement('div');
|
|
76
|
-
d.innerHTML = html;
|
|
77
|
-
for (const a of [...d.querySelectorAll('a')]) {
|
|
78
|
-
const names = a.getAttribute('href') === cat.id
|
|
79
|
-
|| a.textContent.trim().replace(/^@/u, '') === String(cat.slug || '')
|
|
80
|
-
|| a.textContent.trim() === handleOf(cat);
|
|
81
|
-
if (!names) continue;
|
|
82
|
-
const p0 = a.closest('p') || a.parentElement;
|
|
83
|
-
a.remove();
|
|
84
|
-
if (p0 && !p0.textContent.trim() && !p0.querySelector('img,a')) p0.remove();
|
|
85
|
-
}
|
|
86
|
-
return d.innerHTML;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// The author as they are known: their name when the forum kept a card for
|
|
90
|
-
// them, and always the handle, which is the thing that is theirs everywhere.
|
|
91
|
-
function byline(p, author) {
|
|
92
|
-
if (!author) return esc(p.author ? authorLabel(p.author) : '');
|
|
93
|
-
const handle = esc(author.handle);
|
|
94
|
-
const name = author.name && author.name !== author.handle ? esc(author.name) : null;
|
|
95
|
-
const inner = name ? `${name} <span class="dim">${handle}</span>` : handle;
|
|
96
|
-
// Their posts in this forum, not their page elsewhere: that is what a
|
|
97
|
-
// reader here is asking for when they click a name.
|
|
98
|
-
return `<a href="#/who/${encodeURIComponent(author.id)}">${inner}</a>`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Where this post can be READ away from here. A pod answers its post's own
|
|
102
|
-
// address with JSON, so that address is no use to a person and is not shown;
|
|
103
|
-
// a server that publishes a page for it says so, and the link names its host.
|
|
104
|
-
function elsewhere(p) {
|
|
105
|
-
if (!p?.page) return '';
|
|
106
|
-
let host = '';
|
|
107
|
-
try { host = new URL(p.page).host; } catch { return ''; }
|
|
108
|
-
return ` <a href="${esc(p.page)}" class="dim" rel="noopener">on ${esc(host)}</a>`;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
84
|
// Whose post this is, as this browser can tell: a pod account is its actor,
|
|
112
85
|
// a Mastodon account is the profile its server gave us.
|
|
113
86
|
const mine = (p) => {
|
|
@@ -115,77 +88,20 @@ const mine = (p) => {
|
|
|
115
88
|
return !!me && !!p?.author && (p.author === me || p.author === podAcct?.webId);
|
|
116
89
|
};
|
|
117
90
|
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const kids = new Map();
|
|
125
|
-
const roots = [];
|
|
126
|
-
for (const p of posts) {
|
|
127
|
-
const parent = p.inReplyTo && byId.has(p.inReplyTo) && p.inReplyTo !== p.id ? p.inReplyTo : null;
|
|
128
|
-
if (!parent) { roots.push(p); continue; }
|
|
129
|
-
if (!kids.has(parent)) kids.set(parent, []);
|
|
130
|
-
kids.get(parent).push(p);
|
|
131
|
-
}
|
|
132
|
-
const out = [];
|
|
133
|
-
const walk = async (p, depth) => {
|
|
134
|
-
const html = card(p, { author: p.author ? authors.get(p.author) : null, cat, anchor: await cacheKey(p.id) });
|
|
135
|
-
out.push(depth ? `<div class="nest" style="--depth:${Math.min(depth, MAX_DEPTH)}">${html}</div>` : html);
|
|
136
|
-
for (const k of kids.get(p.id) || []) await walk(k, depth + 1);
|
|
137
|
-
};
|
|
138
|
-
for (const r of roots) await walk(r, 0);
|
|
139
|
-
return out;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function topicActs(topicId) {
|
|
143
|
-
if (!iModerate()) return '';
|
|
144
|
-
return `<div class="acts mod" role="group" aria-label="Moderator actions for this topic" data-topic="${esc(topicId)}">
|
|
145
|
-
<button data-act="rename">Rename topic</button>
|
|
146
|
-
<button data-act="unlock">Reopen topic</button>
|
|
147
|
-
<button data-act="pin">Pin topic</button><button data-act="unpin">Unpin topic</button>
|
|
148
|
-
<button data-act="sitepin">Pin site-wide</button><button data-act="siteunpin">Unpin site-wide</button>
|
|
149
|
-
<button class="grave" data-act="lock">Close topic</button><button class="grave" data-act="droptopic">Delete topic</button>
|
|
150
|
-
</div>`;
|
|
91
|
+
// Who is reading, as the drawing (draw.mjs) needs to know it.
|
|
92
|
+
function viewer() {
|
|
93
|
+
const isNew = (topic, at) => !!seen?.isNew(topic, at);
|
|
94
|
+
if (!account()) return { ...draw.NOBODY, isNew };
|
|
95
|
+
return { account: true, pod: !!podAcct, moderates: iModerate(), isNew, mine,
|
|
96
|
+
votedOn: (id) => own().votedOn(id), isSaved: (id) => own().isSaved(id), isJoined: (id) => own().isJoined(id) };
|
|
151
97
|
}
|
|
98
|
+
const drawing = () => ({ viewer: viewer(), addr, handleOf });
|
|
99
|
+
const body = (p, cat) => draw.body(p, cat, drawing());
|
|
100
|
+
const card = (p, opts = {}) => draw.card(p, opts, drawing());
|
|
152
101
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
const up = p.likes || 0;
|
|
157
|
-
const down = p.dislikes || 0;
|
|
158
|
-
// Signed out, the buttons are still buttons: pressing one asks who you are
|
|
159
|
-
// and then casts the vote. Showing the counts as dead text hid the fact that
|
|
160
|
-
// voting was possible at all.
|
|
161
|
-
const mineWay = account() ? own().votedOn(p.id) : 'none';
|
|
162
|
-
const one = (way, mark, n, label) => `<button data-act="vote-${mineWay === way ? 'none' : way}"
|
|
163
|
-
data-was="${mineWay}"${mineWay === way ? ' aria-pressed="true" class="voted"' : ''}
|
|
164
|
-
aria-label="${mineWay === way ? `Take back your vote ${label}` : `Vote ${label} this post`}">${mark} ${n}</button>`;
|
|
165
|
-
return one('up', '▲', up, 'for') + one('down', '▼', down, 'against');
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function card(p, { author = null, cat = null, extra = '', waiting = false, anchor = null } = {}) {
|
|
169
|
-
const at = p.published ? when(p.published) : '';
|
|
170
|
-
// What a screen reader hears before the words, and what each of this post's
|
|
171
|
-
// own buttons is called — a thread of them is not twenty identical "Reply"s.
|
|
172
|
-
const whose = esc(author ? (author.name || author.handle) : (p.author ? authorLabel(p.author) : 'someone'));
|
|
173
|
-
const named = `${waiting ? 'Waiting: p' : 'P'}ost by ${whose}${at ? `, ${esc(at)}` : ''}`;
|
|
174
|
-
return `<article class="post${p.gone ? ' gone' : ''}${extra}" aria-label="${named}"${anchor ? ` id="p-${esc(anchor)}"` : ''}>
|
|
175
|
-
<div class="who"><b>${waiting ? '<span class="dim">waiting for the forum · </span>' : ''}${byline(p, author)}</b>
|
|
176
|
-
<span class="when">${esc(at)}${elsewhere(p)}</span></div>
|
|
177
|
-
<div class="body">${p.gone ? 'This post was removed.' : (body(p, cat) || '<span class="dim">(not readable here)</span>')}</div>
|
|
178
|
-
${p.gone || waiting || !p.id ? '' : `<div class="acts" data-post="${esc(p.id)}">
|
|
179
|
-
<button data-act="reply" aria-label="Reply to ${whose}">Reply</button>
|
|
180
|
-
${votes(p)}
|
|
181
|
-
<button data-act="share" aria-label="Copy a link to the post by ${whose}">Share</button>
|
|
182
|
-
${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>` : ''}
|
|
183
|
-
${mine(p) || !account() ? '' : `<button data-act="report" aria-label="Report the post by ${whose}">Report</button>`}
|
|
184
|
-
${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>' : ''}
|
|
185
|
-
${!mine(p) && iModerate() ? `<button data-act="remove" aria-label="Remove the post by ${whose}">Remove</button>` : ''}
|
|
186
|
-
</div>`}
|
|
187
|
-
</article>`;
|
|
188
|
-
}
|
|
102
|
+
// The page as it arrived drawn is left as it is, unless this reader would be
|
|
103
|
+
// shown something a stranger is not.
|
|
104
|
+
const asDrawn = (drawn) => !!drawn && !account() && !iModerate();
|
|
189
105
|
|
|
190
106
|
const waitingKey = (topicId) => 'bb:waiting:' + topicId;
|
|
191
107
|
const waiting = (topicId) => { try { return (JSON.parse(localStorage.getItem(waitingKey(topicId)) || '[]') || []).filter(w => w?.id); } catch { return []; } };
|
|
@@ -202,7 +118,7 @@ async function load() {
|
|
|
202
118
|
$('main').innerHTML = `<p class="err">Name a forum: <code>${esc(location.origin)}/<forum>/</code></p>`;
|
|
203
119
|
return;
|
|
204
120
|
}
|
|
205
|
-
forum = await read.forum(base);
|
|
121
|
+
forum = boot?.forum || await read.forum(base);
|
|
206
122
|
if (!forum) {
|
|
207
123
|
// A forum that does not exist and one whose pod is down read the same
|
|
208
124
|
// to the reader above; asking once more tells them apart, so a reader is
|
|
@@ -214,22 +130,32 @@ async function load() {
|
|
|
214
130
|
: '<p class="err">The pod that holds this forum is not answering right now, so the forum cannot be shown. Try again later.</p>';
|
|
215
131
|
return;
|
|
216
132
|
}
|
|
217
|
-
document.title = forum.name || 'FediPod-BB';
|
|
218
133
|
$('forum-link').textContent = forum.name || 'Forum';
|
|
134
|
+
$('forum-link').setAttribute('href', addr.front());
|
|
219
135
|
// Who moderates, under the line: the forum's own list, each name linking to
|
|
220
136
|
// the page that account keeps.
|
|
221
|
-
|
|
222
|
-
|
|
137
|
+
if (!boot) $('mods-who').innerHTML = draw.modsWho((forum.admins || []).map(a => ({ id: a.id, handle: a.handle })));
|
|
138
|
+
takeDrawn();
|
|
223
139
|
route();
|
|
224
140
|
}
|
|
225
141
|
|
|
226
142
|
function crumbs(parts) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
143
|
+
const trail = draw.crumbs(parts);
|
|
144
|
+
$('crumbs').hidden = trail.hidden;
|
|
145
|
+
$('crumbs').innerHTML = trail.html;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The drawn page is the first view's only. After it, every view is the
|
|
149
|
+
// page's own to read and draw.
|
|
150
|
+
let first = true;
|
|
151
|
+
const drawnFor = (kind, slug = null, tid = null) => {
|
|
152
|
+
if (!first || !boot?.view || boot.view.kind !== kind) return null;
|
|
153
|
+
if ((slug && boot.view.slug !== slug) || (tid && boot.view.tid !== tid)) return null;
|
|
154
|
+
// A reader signed in to a pod may read what a stranger may not, so a forum
|
|
155
|
+
// with a members-only category is read again as them.
|
|
156
|
+
if (podAcct && cats().some(c => c.private)) return null;
|
|
157
|
+
return boot;
|
|
158
|
+
};
|
|
233
159
|
|
|
234
160
|
// Who the page is holding, in the top row: the handle a reader posts under,
|
|
235
161
|
// and the way in when there is nobody. Signing in from here is what makes the
|
|
@@ -240,15 +166,28 @@ function paintWho() {
|
|
|
240
166
|
$('who-act').textContent = acct ? 'Sign out' : 'Sign in';
|
|
241
167
|
}
|
|
242
168
|
|
|
169
|
+
// Which view an address names, leaving out the post a link points at.
|
|
170
|
+
const viewKey = () => location.pathname + (location.hash.startsWith('#/') ? location.hash : '');
|
|
171
|
+
let routed = null;
|
|
172
|
+
|
|
243
173
|
async function route() {
|
|
174
|
+
routed = viewKey();
|
|
244
175
|
paintWho();
|
|
245
176
|
// Searching and starting a topic belong to the forum's own view; the rest of
|
|
246
177
|
// the page keeps the moderators line and nothing else.
|
|
247
|
-
|
|
178
|
+
const hash = location.hash.startsWith('#/') ? location.hash.slice(2) : '';
|
|
179
|
+
const [kind, a, b, c] = hash.split('/');
|
|
180
|
+
// At the forum's own host the front page, a category and a topic have
|
|
181
|
+
// addresses of their own, and an old link to one of them is taken there.
|
|
182
|
+
if (place.root && location.hash.startsWith('#/') && !HASH_VIEWS.has(kind)) {
|
|
183
|
+
const to = kind === 'c' && a ? addr.cat(a) : kind === 't' && a && b ? addr.topic(a, b, c || null) : addr.front();
|
|
184
|
+
if (to.split('#')[0] !== location.pathname) { location.replace(to); return undefined; }
|
|
185
|
+
history.replaceState(null, '', to);
|
|
186
|
+
return route();
|
|
187
|
+
}
|
|
188
|
+
if (!first || !boot) $('mods-acts').innerHTML = '';
|
|
248
189
|
const mine = ++view;
|
|
249
190
|
void mine;
|
|
250
|
-
const hash = location.hash.replace(/^#\/?/u, '');
|
|
251
|
-
const [kind, a, b, c] = hash.split('/');
|
|
252
191
|
$('reply-row').hidden = true;
|
|
253
192
|
$('reply-dlg').close();
|
|
254
193
|
if (kind === 'replies') return showReplies();
|
|
@@ -256,8 +195,10 @@ async function route() {
|
|
|
256
195
|
if (kind === 'queue') return showQueue();
|
|
257
196
|
if (kind === 'settings') return showSettings();
|
|
258
197
|
if (kind === 'who' && a) return showWho(decodeURIComponent(a));
|
|
259
|
-
|
|
260
|
-
|
|
198
|
+
const at = place.root ? viewOfPath(location.pathname, place.root)
|
|
199
|
+
: kind === 'c' && a ? { kind: 'cat', slug: a } : kind === 't' && a && b ? { kind: 'topic', slug: a, tid: b, post: c || null } : { kind: 'front' };
|
|
200
|
+
if (at.kind === 'cat') { if (feedFilter !== at.slug) shown = 30; feedFilter = at.slug; return showForum(); }
|
|
201
|
+
if (at.kind === 'topic') return showTopic(at.slug, at.tid, at.post || (/^#p-([0-9a-f]+)$/u.exec(location.hash)?.[1] ?? null));
|
|
261
202
|
// `#/` IS the whole forum, so arriving at it clears the category. Leaving the
|
|
262
203
|
// filter where it was meant Home redrew whatever category you had been in,
|
|
263
204
|
// and All — whose link is `#/` — redrew the same one, which from the reader's
|
|
@@ -275,13 +216,15 @@ let view = 0; // which view the page is on; an older one must n
|
|
|
275
216
|
let order = 'date'; // which column the index is ordered by
|
|
276
217
|
let down = true; // and which way
|
|
277
218
|
let finding = ''; // what the reader is looking for, if anything
|
|
219
|
+
// The views that are this reader's own, or a moderator's: after `#` everywhere.
|
|
220
|
+
const HASH_VIEWS = new Set(['replies', 'bookmarks', 'saved', 'queue', 'settings', 'who']);
|
|
278
221
|
// What this reader keeps for themselves, under the account they signed in as.
|
|
279
222
|
const own = () => keptByReader({ storage: localStorage, who: account()?.handle || null });
|
|
280
223
|
|
|
281
224
|
// Answers to this reader's own posts, newest first. The forum publishes what
|
|
282
225
|
// each post answers, so nobody has to be told whom to notify.
|
|
283
226
|
async function showReplies() {
|
|
284
|
-
crumbs([['Home',
|
|
227
|
+
crumbs([['Home', addr.front()], ['Replies to you', null]]);
|
|
285
228
|
const me = podAcct?.actor || login.account()?.url;
|
|
286
229
|
if (!me) { $('main').innerHTML = '<p class="empty">Sign in to see answers to your posts.</p>'; return; }
|
|
287
230
|
$('main').innerHTML = '<p class="dim">Looking…</p>';
|
|
@@ -294,7 +237,7 @@ async function showReplies() {
|
|
|
294
237
|
const cat = cats().find(c => c.id === p.category) || null;
|
|
295
238
|
const who = cat && p.author ? await read.author(cat.base, p.author) : null;
|
|
296
239
|
const tid = p.topic ? p.topic.split('/').pop() : null;
|
|
297
|
-
const href = cat && tid ?
|
|
240
|
+
const href = esc(cat && tid ? addr.topic(cat.slug, tid, await cacheKey(p.id)) : addr.front());
|
|
298
241
|
items.push(`<li><a class="title" href="${href}">${esc(p.topicName || 'Topic')}</a>
|
|
299
242
|
<div class="meta">${who ? esc(who.handle) : esc(authorLabel(p.author || ''))} · ${esc(when(p.published))}</div>
|
|
300
243
|
<article class="post"><div class="body">${cleanHtml(p.content)}</div></article></li>`);
|
|
@@ -304,13 +247,13 @@ async function showReplies() {
|
|
|
304
247
|
|
|
305
248
|
// Posts this reader kept. The list is theirs and lives in this browser.
|
|
306
249
|
function showSaved() {
|
|
307
|
-
crumbs([['Home',
|
|
250
|
+
crumbs([['Home', addr.front()], ['Bookmarked', null]]);
|
|
308
251
|
const rows = own().saved();
|
|
309
252
|
if (!rows.length) { $('main').innerHTML = '<p class="empty">Nothing bookmarked yet. Use Bookmark on a post.</p>'; return; }
|
|
310
253
|
$('main').innerHTML = `<ul class="list">${rows.map(r => {
|
|
311
254
|
const cat = cats().find(c => c.slug === r.cat) || null;
|
|
312
255
|
const tid = r.topic ? r.topic.split('/').pop() : null;
|
|
313
|
-
const href = cat && tid ?
|
|
256
|
+
const href = esc(cat && tid ? addr.topic(cat.slug, tid) : addr.front());
|
|
314
257
|
return `<li><a class="title" href="${href}">${esc(r.text || r.id)}</a>
|
|
315
258
|
<div class="meta">bookmarked ${esc(when(r.at))}${cat ? ' · ' + esc(cat.name) : ''}</div></li>`;
|
|
316
259
|
}).join('')}</ul>`;
|
|
@@ -320,7 +263,7 @@ function showSaved() {
|
|
|
320
263
|
// and asks not yet acted on. Read from the forum's pod with the moderator's
|
|
321
264
|
// own login — it is not public and never passes through the Gateway.
|
|
322
265
|
async function showQueue() {
|
|
323
|
-
crumbs([['Home',
|
|
266
|
+
crumbs([['Home', addr.front()], ['Queue', null]]);
|
|
324
267
|
if (!podAcct) { $('main').innerHTML = '<p class="empty">The queue is read with your pod account.</p>'; return; }
|
|
325
268
|
$('main').innerHTML = '<p class="dim">Reading the queue…</p>';
|
|
326
269
|
try {
|
|
@@ -351,7 +294,7 @@ async function showQueue() {
|
|
|
351
294
|
}
|
|
352
295
|
|
|
353
296
|
async function showWho(handleOrId) {
|
|
354
|
-
crumbs([['Home',
|
|
297
|
+
crumbs([['Home', addr.front()], ['Someone', null]]);
|
|
355
298
|
$('main').innerHTML = '<p class="dim">Looking…</p>';
|
|
356
299
|
const latest = await read.latest(base, { limit: 200 });
|
|
357
300
|
const theirs = latest.filter(p => p.author === handleOrId);
|
|
@@ -364,7 +307,7 @@ async function showWho(handleOrId) {
|
|
|
364
307
|
// is, so where it names no page the account itself is asked.
|
|
365
308
|
const said = kept?.url ? null : await read.actorCard(handleOrId).catch(() => null);
|
|
366
309
|
const card = kept && said ? { ...kept, url: said.url, name: kept.name || said.name } : (kept || said);
|
|
367
|
-
crumbs([['Home',
|
|
310
|
+
crumbs([['Home', addr.front()], [card?.handle || authorLabel(handleOrId), null]]);
|
|
368
311
|
const named = esc(card?.handle || authorLabel(handleOrId));
|
|
369
312
|
// A word with them and nobody else. Only from a pod account: the message is
|
|
370
313
|
// written on the sender's own pod before it is handed over.
|
|
@@ -388,7 +331,7 @@ async function showWho(handleOrId) {
|
|
|
388
331
|
for (const p of theirs) {
|
|
389
332
|
const c = cats().find(x => x.id === p.category) || null;
|
|
390
333
|
const tid = p.topic ? p.topic.split('/').pop() : null;
|
|
391
|
-
const href = c && tid ?
|
|
334
|
+
const href = esc(c && tid ? addr.topic(c.slug, tid, await cacheKey(p.id)) : addr.front());
|
|
392
335
|
items.push(`<li><a class="title" href="${href}">${esc(p.topicName || 'Topic')}</a>
|
|
393
336
|
<div class="meta">${c ? esc(c.name) + ' · ' : ''}${esc(when(p.published))} · ▲ ${p.likes || 0} ▼ ${p.dislikes || 0}</div>
|
|
394
337
|
<article class="post"><div class="body">${cleanHtml(p.content)}</div></article></li>`);
|
|
@@ -409,7 +352,7 @@ async function knowModerators() {
|
|
|
409
352
|
}
|
|
410
353
|
|
|
411
354
|
async function showSettings() {
|
|
412
|
-
crumbs([['Home',
|
|
355
|
+
crumbs([['Home', addr.front()], ['Settings', null]]);
|
|
413
356
|
await knowModerators();
|
|
414
357
|
if (!iModerate()) { $('main').innerHTML = '<p class="empty">Only this forum\'s moderators may change it.</p>'; return; }
|
|
415
358
|
if (!podAcct) { $('main').innerHTML = '<p class="empty">Changing the forum is done with your pod account.</p>'; return; }
|
|
@@ -467,6 +410,18 @@ async function showSettings() {
|
|
|
467
410
|
let index = null;
|
|
468
411
|
const INDEX_STALE_MS = 60_000;
|
|
469
412
|
function forgetIndex() { index = null; authors.clear(); }
|
|
413
|
+
// A post just sent from here is listed once its server has delivered it to
|
|
414
|
+
// the forum. Until then the index is read again every few seconds, for half a
|
|
415
|
+
// minute, instead of being kept for a minute without it.
|
|
416
|
+
const SENT_EVERY_MS = 5_000;
|
|
417
|
+
const SENT_WAIT_MS = 30_000;
|
|
418
|
+
// How long a post of this reader's may wait for the forum before the page
|
|
419
|
+
// offers to send it again.
|
|
420
|
+
const RESEND_AFTER_MS = 3_600_000;
|
|
421
|
+
let sent = null; // { id, until }
|
|
422
|
+
const waitingOnSent = (idx) => sentNotListed(sent, idx?.latest);
|
|
423
|
+
const indexHolds = (idx, want) => !!idx && idx.limit >= want
|
|
424
|
+
&& Date.now() - idx.at < (waitingOnSent(idx) ? SENT_EVERY_MS : INDEX_STALE_MS);
|
|
470
425
|
|
|
471
426
|
// Who wrote it, and the digest a topic link carries. Both are asked once per
|
|
472
427
|
// ROW, and the same people and the same posts come round again every time the
|
|
@@ -486,224 +441,110 @@ function digestOnce(id) {
|
|
|
486
441
|
}
|
|
487
442
|
|
|
488
443
|
async function readIndex(want) {
|
|
489
|
-
if (index
|
|
490
|
-
|
|
491
|
-
const site = new Set(await read.featured(base));
|
|
492
|
-
const pinned = new Map();
|
|
493
|
-
const mods = new Map();
|
|
494
|
-
for (const c of cats()) {
|
|
495
|
-
pinned.set(c.id, new Set(await read.featured(c.base)));
|
|
496
|
-
mods.set(c.id, await read.moderators(c.base));
|
|
497
|
-
}
|
|
498
|
-
index = { at: Date.now(), limit: want, latest, site, pinned, mods };
|
|
444
|
+
if (indexHolds(index, want)) return index;
|
|
445
|
+
index = await draw.readIndex(read, base, cats(), want);
|
|
499
446
|
return index;
|
|
500
447
|
}
|
|
501
448
|
|
|
449
|
+
// The drawn page's index, authors and digests, as if this page had read them.
|
|
450
|
+
function takeDrawn() {
|
|
451
|
+
if (!boot || (podAcct && cats().some(c => c.private))) return;
|
|
452
|
+
if (boot.index) index = { ...boot.index, at: Date.now() };
|
|
453
|
+
for (const [key, card] of Object.entries(boot.facts?.authors || {})) authors.set(key, Promise.resolve(card));
|
|
454
|
+
for (const [id, digest] of Object.entries(boot.facts?.digests || {})) digests.set(id, Promise.resolve(digest));
|
|
455
|
+
}
|
|
456
|
+
|
|
502
457
|
async function showForum() {
|
|
503
458
|
const mineView = view;
|
|
504
459
|
const here = cats().find(c => c.slug === feedFilter) || null;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// category — so this no longer decides what to fetch, only what to show.
|
|
511
|
-
const showMods = (idx) => {
|
|
512
|
-
if (here) { moderators = idx.mods.get(here.id) || []; } else {
|
|
513
|
-
const all = new Set();
|
|
514
|
-
for (const list of idx.mods.values()) for (const m of list) all.add(m);
|
|
515
|
-
moderators = [...all];
|
|
516
|
-
}
|
|
517
|
-
// The line under the rule says who they are. It is painted from the
|
|
518
|
-
// forum's own list when the page loads; this keeps it with the categories'
|
|
519
|
-
// lists, which is where a moderator added while you read appears first.
|
|
520
|
-
paintMods(moderators);
|
|
521
|
-
};
|
|
522
|
-
const chip = (slug, label) => `<a class="chip${feedFilter === slug ? ' on' : ''}"${feedFilter === slug ? ' aria-current="page"' : ''} href="${slug === 'all' ? '#/' : `#/c/${esc(slug)}`}">${esc(label)}</a>`;
|
|
523
|
-
// Starting a topic from the front page: in the category being shown, or the
|
|
524
|
-
// first one when the whole forum is.
|
|
525
|
-
const into = cats().find(c => c.slug === feedFilter) || cats()[0] || null;
|
|
526
|
-
// Built where it is drawn, not once above: whether this reader moderates is
|
|
527
|
-
// read with the index below, and a row composed before that always said no.
|
|
528
|
-
const head = () => `<p class="hint chips">Categories: ${chip('all', 'All')}${cats().map(c => chip(c.slug || '', c.name)).join('')}
|
|
529
|
-
${account() ? '<a class="chip" href="#/replies">Replies to you</a><a class="chip" href="#/bookmarks">Bookmarked</a>' : ''}
|
|
530
|
-
${iModerate() ? '<a class="chip" href="#/queue">Queue</a><a class="chip" href="#/settings">Settings</a>' : ''}
|
|
531
|
-
<span class="rowend">
|
|
532
|
-
${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>` : ''}
|
|
533
|
-
</span></p>`;
|
|
534
|
-
// Search and New topic ride at the right end of the moderators row, which is
|
|
535
|
-
// the one line the page keeps above whatever is being read.
|
|
536
|
-
const acts = () => `<label class="vh" for="find">Search this forum</label>
|
|
537
|
-
<input type="text" id="find" placeholder="Search" value="${esc(finding)}" autocomplete="off"${finding ? '' : ' hidden'}>
|
|
538
|
-
<button data-act="search"${finding ? ' hidden' : ''}>Search</button>
|
|
539
|
-
${into ? `<button data-act="newtopic" data-slug="${esc(into.slug || '')}">New topic</button>` : ''}`;
|
|
540
|
-
if (mineView !== view) return;
|
|
460
|
+
// As it arrived, when it arrived drawn for a stranger and this reader is one.
|
|
461
|
+
const keep = first && asDrawn(drawnFor(here ? 'cat' : 'front', here?.slug || null)) && !finding;
|
|
462
|
+
first = false;
|
|
463
|
+
const trail = here ? [['Home', addr.front()], [here.name, addr.cat(here.slug)]] : [['Home', addr.front()]];
|
|
464
|
+
if (!keep) crumbs(trail);
|
|
541
465
|
// Enough posts to fill a page of TOPICS: several posts can belong to one.
|
|
542
466
|
const want = Math.min(300, shown * 5);
|
|
543
467
|
// Only say "Loading" when something is actually going to be waited for. The
|
|
544
468
|
// reader changing category has everything already; blanking the page for
|
|
545
469
|
// them was the whole of the delay they could see.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
$('
|
|
549
|
-
$('main').innerHTML = head() + '<p class="dim">Loading…</p>';
|
|
470
|
+
if (!indexHolds(index, want) && !waitingOnSent(index)) {
|
|
471
|
+
$('mods-acts').innerHTML = draw.frontActs({ cats: cats(), filter: feedFilter, finding });
|
|
472
|
+
$('main').innerHTML = draw.frontChips({ cats: cats(), filter: feedFilter, here }, drawing()) + '<p class="dim">Loading…</p>';
|
|
550
473
|
say('Loading the latest posts');
|
|
551
474
|
}
|
|
552
475
|
const idx = await readIndex(want);
|
|
553
476
|
if (mineView !== view) return;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
// One row per topic: the newest post in it stands for it, which is what a
|
|
570
|
-
// reader wants to open — the thing they have not read.
|
|
571
|
-
const byTopic = new Map();
|
|
572
|
-
for (const p of mine) {
|
|
573
|
-
if (!p.topic) continue;
|
|
574
|
-
const held = byTopic.get(p.topic);
|
|
575
|
-
if (!held || String(p.published || '') > String(held.published || '')) byTopic.set(p.topic, p);
|
|
576
|
-
}
|
|
577
|
-
const rank = (p) => (pins.site.has(p.topic) ? 2 : pins.cat.has(p.topic) ? 1 : 0);
|
|
578
|
-
// Pinned first whatever the order; then the column the reader chose.
|
|
579
|
-
// Posts, not replies: a topic with nobody answering yet still HAS the post
|
|
580
|
-
// that started it, and a column reading 0 beside a topic you can open and
|
|
581
|
-
// read says the wrong thing. The count the forum keeps is of replies, so the
|
|
582
|
-
// opening post is the one added here.
|
|
583
|
-
const by = (p) => (order === 'replies' ? (p.topicReplies || 0) + 1 : String(p.published || ''));
|
|
584
|
-
const topics = [...byTopic.values()].sort((a, b) => rank(b) - rank(a)
|
|
585
|
-
|| (down ? (by(a) > by(b) ? -1 : by(a) < by(b) ? 1 : 0) : (by(a) > by(b) ? 1 : by(a) < by(b) ? -1 : 0)));
|
|
586
|
-
const page = topics.slice(0, shown);
|
|
587
|
-
if (!topics.length) {
|
|
588
|
-
// A members-only category that will not answer this reader looks exactly
|
|
589
|
-
// like an empty one. Ask it a question only a member can have answered.
|
|
590
|
-
if (here && !(await read.topics(here.base)).total && !(await read.canRead(here.base))) {
|
|
591
|
-
if (mineView !== view) return;
|
|
592
|
-
$('mods-acts').innerHTML = acts();
|
|
593
|
-
$('main').innerHTML = head() + (podAcct
|
|
594
|
-
? `<p class="empty row">This category is for its members.
|
|
595
|
-
<button class="new" data-act="ask-join" data-cat="${esc(here.id)}">Request membership</button></p>`
|
|
596
|
-
: `<p class="empty row">This category is for its members, and membership needs a FediPod account.
|
|
597
|
-
<a class="big" href="${esc(front)}/new-account">Get a FediPod account</a></p>`);
|
|
598
|
-
say('This category is for its members');
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
// The one thing to do on an empty page is the one thing offered.
|
|
602
|
-
if (mineView !== view) return;
|
|
603
|
-
$('mods-acts').innerHTML = acts();
|
|
604
|
-
$('main').innerHTML = head() + '<p class="empty">Nothing posted here yet. Use the button at the upper right to create a topic.</p>';
|
|
605
|
-
say('Nothing posted here yet');
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
const rows = [];
|
|
609
|
-
let fresh = 0;
|
|
477
|
+
if (waitingOnSent(idx)) setTimeout(() => { if (mineView === view) showForum(); }, SENT_EVERY_MS);
|
|
478
|
+
// Who moderates what is on screen. Showing the whole forum, that is
|
|
479
|
+
// whoever moderates its categories — otherwise a moderator looking at
|
|
480
|
+
// everything would look like a stranger. The line under the rule says who
|
|
481
|
+
// they are: painted from the forum's own list when the page loads, and kept
|
|
482
|
+
// with the categories' lists, which is where a moderator added while you
|
|
483
|
+
// read appears first.
|
|
484
|
+
moderators = draw.moderatorsOf(idx, here);
|
|
485
|
+
if (!keep) paintMods(moderators);
|
|
486
|
+
const rows = draw.frontRows({ cats: cats(), idx, filter: feedFilter, finding, order, down, shown });
|
|
487
|
+
// A members-only category that will not answer this reader looks exactly
|
|
488
|
+
// like an empty one. Ask it a question only a member can have answered.
|
|
489
|
+
const membersOnly = !rows.topics.length && !!here
|
|
490
|
+
&& !(await read.topics(here.base)).total && !(await read.canRead(here.base));
|
|
610
491
|
// Every row's author and digest at once rather than one row at a time: they
|
|
611
492
|
// do not depend on each other, and waiting for each in turn is the whole of
|
|
612
493
|
// the delay between choosing a category and seeing it.
|
|
613
|
-
const
|
|
614
|
-
const cat = cats().find(c => c.id === p.category) || null;
|
|
615
|
-
return {
|
|
616
|
-
cat,
|
|
617
|
-
who: cat && p.author ? await authorOnce(cat.base, p.author) : null,
|
|
618
|
-
digest: await digestOnce(p.id),
|
|
619
|
-
};
|
|
620
|
-
}));
|
|
494
|
+
const facts = await draw.rowFacts(rows.page, cats(), authorOnce, digestOnce);
|
|
621
495
|
if (mineView !== view) return;
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const isNew = !!seen?.isNew(p.topic, p.published);
|
|
630
|
-
if (isNew) fresh += 1;
|
|
631
|
-
const byline = who ? who.handle : (p.author ? authorLabel(p.author) : '');
|
|
632
|
-
rows.push(`<tr>
|
|
633
|
-
<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
|
|
634
|
-
? ' <span class="badge">New<span class="vh"> since you last opened this topic</span></span>' : ''}</td>
|
|
635
|
-
<td>${cat ? esc(cat.name) : ''}</td>
|
|
636
|
-
<td>${byline ? (p.author
|
|
637
|
-
? `<a class="who" href="#/who/${encodeURIComponent(p.author)}" title="${esc(byline)}">${esc(byline)}</a>`
|
|
638
|
-
: `<span class="who" title="${esc(byline)}">${esc(byline)}</span>`) : ''}</td>
|
|
639
|
-
<td>${esc(when(p.published))}</td>
|
|
640
|
-
<td>${Number.isFinite(p.topicReplies) ? p.topicReplies + 1 : ''}</td>
|
|
641
|
-
</tr>`);
|
|
496
|
+
const out = draw.drawFront({ forum, cats: cats(), filter: feedFilter, finding, order, down, rows, facts, membersOnly, front }, drawing());
|
|
497
|
+
document.title = out.title;
|
|
498
|
+
say(out.say);
|
|
499
|
+
if (!keep || out.fresh) {
|
|
500
|
+
crumbs(trail);
|
|
501
|
+
$('mods-acts').innerHTML = out.acts;
|
|
502
|
+
$('main').innerHTML = out.main;
|
|
642
503
|
}
|
|
643
|
-
// A table, so a reader can run their eye down any one of the four things
|
|
644
|
-
// an entry says.
|
|
645
|
-
const more = Math.max(0, topics.length - page.length) + (latest.more || 0);
|
|
646
|
-
if (mineView !== view) return;
|
|
647
|
-
$('mods-acts').innerHTML = acts();
|
|
648
|
-
$('main').innerHTML = head() + `<div class="scroll" role="region" aria-label="Topics, most recently posted in first" tabindex="0"><table class="index">
|
|
649
|
-
<thead><tr><th scope="col">Topic</th><th scope="col">Category</th><th scope="col">Latest by</th>
|
|
650
|
-
<th scope="col" aria-sort="${order === 'date' ? (down ? 'descending' : 'ascending') : 'none'}">
|
|
651
|
-
<button class="sort" data-act="order" data-order="date">Date ${order === 'date' ? (down ? '▼' : '▲') : '<span class="dim">▽</span>'}</button></th>
|
|
652
|
-
<th scope="col" aria-sort="${order === 'replies' ? (down ? 'descending' : 'ascending') : 'none'}">
|
|
653
|
-
<button class="sort" data-act="order" data-order="replies">Posts ${order === 'replies' ? (down ? '▼' : '▲') : '<span class="dim">▽</span>'}</button></th></tr></thead>
|
|
654
|
-
<tbody>${rows.join('')}</tbody></table>
|
|
655
|
-
${more ? `<p class="row"><button data-act="more">Show ${Math.min(more, 30)} more of ${more}</button></p>` : ''}</div>`;
|
|
656
|
-
say(`${rows.length} topic${rows.length === 1 ? '' : 's'}${fresh ? `, ${fresh} with something new` : ''}`);
|
|
657
504
|
// The box takes the keyboard only when there is something in it to scroll:
|
|
658
505
|
// a tab stop that does nothing is one more press between a reader and the page.
|
|
659
|
-
const
|
|
660
|
-
if (
|
|
506
|
+
const scroller = $('main').querySelector('.scroll');
|
|
507
|
+
if (scroller) { if (scroller.scrollHeight > scroller.clientHeight) scroller.tabIndex = 0; else scroller.removeAttribute('tabindex'); }
|
|
661
508
|
}
|
|
662
509
|
|
|
663
|
-
|
|
664
510
|
async function showTopic(slug, tid, atPost = null) {
|
|
511
|
+
const drawn = drawnFor('topic', slug, tid);
|
|
512
|
+
const keep = first && asDrawn(drawn);
|
|
513
|
+
first = false;
|
|
665
514
|
const cat = catBySlug(slug);
|
|
666
515
|
if (!cat) { $('main').innerHTML = '<p class="err">No such category.</p>'; return; }
|
|
667
516
|
const topicId = cat.base + 'ap/topic/' + tid;
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
crumbs([['Home', '#/'], [cat.name, `#/c/${slug}`], [t.name, null]]);
|
|
675
|
-
const posts = [];
|
|
676
|
-
for (const id of t.posts) posts.push({ id, ...(await read.post(cat.base, id)) });
|
|
677
|
-
const authors = new Map();
|
|
678
|
-
for (const p of posts) {
|
|
679
|
-
if (!p.author || authors.has(p.author)) continue;
|
|
680
|
-
authors.set(p.author, await read.author(cat.base, p.author));
|
|
517
|
+
let got = drawn?.topic?.topicId === topicId ? drawn.topic : null;
|
|
518
|
+
if (!got) {
|
|
519
|
+
crumbs([['Home', addr.front()], [cat.name, addr.cat(slug)], ['topic', null]]);
|
|
520
|
+
$('main').innerHTML = '<p class="dim">Loading…</p>';
|
|
521
|
+
say('Loading the topic');
|
|
522
|
+
got = await draw.readTopic(read, cat, tid);
|
|
681
523
|
}
|
|
524
|
+
moderators = got.moderators || [];
|
|
525
|
+
const { t, posts = [] } = got;
|
|
682
526
|
// Opening a topic reads it, up to the newest post that was in it.
|
|
683
|
-
seen?.markRead(topicId, seen.newest(posts));
|
|
684
|
-
const placed = new Set(t
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
${(await threaded(posts, authors, cat)).join('')}
|
|
691
|
-
${pending.map(w => card({ author: w.author, published: w.at, content: toHtml(w.text) },
|
|
692
|
-
{ cat, extra: ' waiting', waiting: true })).join('')}`;
|
|
693
|
-
replyBox({ cat, title: 'Reply', inReplyToUrl: t.posts[t.posts.length - 1] || null, topicId });
|
|
694
|
-
$('reply-row').hidden = true; // the thread's own button opens it
|
|
527
|
+
if (t) seen?.markRead(topicId, seen.newest(posts));
|
|
528
|
+
const placed = new Set(t?.posts || []);
|
|
529
|
+
// A post of this reader's own that has waited an hour can be sent again.
|
|
530
|
+
const resendable = (w) => !!podAcct && (w.author === podAcct.handle || w.author === podAcct.actor)
|
|
531
|
+
&& Date.now() - Date.parse(w.at) > RESEND_AFTER_MS;
|
|
532
|
+
const pending = t ? waiting(topicId).filter(w => !placed.has(w.id))
|
|
533
|
+
.map(w => ({ id: w.id, author: w.author, published: w.at, content: toHtml(w.text), resend: resendable(w) })) : [];
|
|
695
534
|
// A closed topic takes nothing more: the forum would refuse it anyway, so
|
|
696
|
-
// the
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
document.querySelector('.topline')?.after(said);
|
|
535
|
+
// the drawing offers no way to add to it.
|
|
536
|
+
const out = draw.drawTopic({ cat, slug, ...got, pending }, drawing());
|
|
537
|
+
document.title = out.title;
|
|
538
|
+
if (!keep || pending.length) {
|
|
539
|
+
crumbs([['Home', addr.front()], [cat.name, addr.cat(slug)], [t ? t.name : 'topic', null]]);
|
|
540
|
+
$('main').innerHTML = out.main;
|
|
703
541
|
}
|
|
542
|
+
say(out.say);
|
|
543
|
+
if (!t) return;
|
|
544
|
+
replyBox({ cat, title: 'Reply', inReplyToUrl: t.posts[t.posts.length - 1] || null, topicId });
|
|
545
|
+
$('reply-row').hidden = true; // the thread's own button opens it
|
|
704
546
|
// Arriving from the front page: the post that was linked to, in view and
|
|
705
547
|
// marked, rather than the top of a thread it sits somewhere inside.
|
|
706
|
-
say(`${t.name}, ${posts.length} post${posts.length === 1 ? '' : 's'}`);
|
|
707
548
|
if (atPost) {
|
|
708
549
|
const el = document.getElementById('p-' + atPost);
|
|
709
550
|
// Taken to, not just scrolled to: a keyboard and a screen reader both land
|
|
@@ -745,8 +586,8 @@ async function signIn(handleInput, noteId = 'fedi-note') {
|
|
|
745
586
|
if (/^https?:\/\//u.test(typed)) {
|
|
746
587
|
$(noteId).textContent = 'Asking your pod…';
|
|
747
588
|
try {
|
|
748
|
-
const { authorizationUrl, storageRoot } = await pod.signInWithWebId(typed,
|
|
749
|
-
sessionStorage.setItem('bb:return',
|
|
589
|
+
const { authorizationUrl, storageRoot } = await pod.signInWithWebId(typed, signInAddress);
|
|
590
|
+
sessionStorage.setItem('bb:return', whereNow());
|
|
750
591
|
sessionStorage.setItem('bb:pod', JSON.stringify({ handle: authorLabel(typed), actor: typed, podHome: storageRoot }));
|
|
751
592
|
location.href = authorizationUrl;
|
|
752
593
|
} catch (e) { $(noteId).textContent = e.message; }
|
|
@@ -760,7 +601,7 @@ async function signIn(handleInput, noteId = 'fedi-note') {
|
|
|
760
601
|
const said = await serverKind(host, front, undefined, handle);
|
|
761
602
|
const kind = said.kind;
|
|
762
603
|
if (kind === 'mastodon-api') {
|
|
763
|
-
sessionStorage.setItem('bb:return',
|
|
604
|
+
sessionStorage.setItem('bb:return', whereNow());
|
|
764
605
|
location.href = await login.begin(host);
|
|
765
606
|
return;
|
|
766
607
|
}
|
|
@@ -768,9 +609,9 @@ async function signIn(handleInput, noteId = 'fedi-note') {
|
|
|
768
609
|
note.textContent = '';
|
|
769
610
|
if (kind === 'fedipod') {
|
|
770
611
|
if (!said.issuer || !said.actor) throw new Error(`${host} did not say where ${handleInput} signs in`);
|
|
771
|
-
sessionStorage.setItem('bb:return',
|
|
612
|
+
sessionStorage.setItem('bb:return', whereNow());
|
|
772
613
|
sessionStorage.setItem('bb:pod', JSON.stringify({ handle: `@${handle}@${host}`, actor: said.actor, podHome: said.podHome }));
|
|
773
|
-
location.href = await pod.signIn({ issuer: said.issuer, redirectUri:
|
|
614
|
+
location.href = await pod.signIn({ issuer: said.issuer, redirectUri: signInAddress });
|
|
774
615
|
return;
|
|
775
616
|
}
|
|
776
617
|
note.textContent = kind === 'lemmy'
|
|
@@ -843,6 +684,7 @@ $('main').addEventListener('click', async (e) => {
|
|
|
843
684
|
if (act === 'share') return share(id, b);
|
|
844
685
|
if (act === 'report') return report(id);
|
|
845
686
|
if (act === 'remove') return modRemove(id);
|
|
687
|
+
if (act === 'resend') return resendPost(id, b);
|
|
846
688
|
const topic = b.dataset.topic || b.closest('[data-topic]')?.dataset.topic;
|
|
847
689
|
if (act === 'more') { shown += 30; return showForum(); }
|
|
848
690
|
if (act === 'order') {
|
|
@@ -975,8 +817,9 @@ async function openEdit(id) {
|
|
|
975
817
|
// The address of this post on this site, for pasting anywhere. The thread is
|
|
976
818
|
// what a person wants to open, and the post is named in it.
|
|
977
819
|
async function share(id, btn) {
|
|
978
|
-
const
|
|
979
|
-
const
|
|
820
|
+
const tid = (replyCtx?.topicId || '').split('/').pop() || (await topicOf(id)) || '';
|
|
821
|
+
const to = addr.topic(replyCtx.cat.slug, tid, await cacheKey(id));
|
|
822
|
+
const link = place.root ? location.origin + to : `${location.origin}${location.pathname}${location.search}${to}`;
|
|
980
823
|
try { await navigator.clipboard.writeText(link); btn.textContent = 'Copied'; say('Link copied'); }
|
|
981
824
|
catch { window.prompt('Copy this address', link); return; }
|
|
982
825
|
setTimeout(() => { btn.textContent = 'Share'; }, 1500);
|
|
@@ -986,8 +829,27 @@ async function share(id, btn) {
|
|
|
986
829
|
// thread: the card sits inside the topic's own list item.
|
|
987
830
|
async function topicOf(id) {
|
|
988
831
|
const li = [...document.querySelectorAll('ul.list li')].find(el => el.querySelector(`[data-post="${CSS.escape(id)}"]`));
|
|
989
|
-
const
|
|
990
|
-
|
|
832
|
+
const href = li?.querySelector('a.title')?.getAttribute('href') || '';
|
|
833
|
+
if (href.startsWith('#/t/')) return href.split('/')[3] || null;
|
|
834
|
+
return place.root ? viewOfPath(new URL(href, location.href).pathname, place.root).tid || null : null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// One of this reader's own posts the forum never took in, handed to it again.
|
|
838
|
+
// It stays marked as waiting until the forum lists it.
|
|
839
|
+
async function resendPost(id, btn) {
|
|
840
|
+
const cat = replyCtx?.cat;
|
|
841
|
+
if (!cat || !podAcct || !id) return;
|
|
842
|
+
btn.disabled = true;
|
|
843
|
+
try {
|
|
844
|
+
const inbox = await pod.podInboxOf(cat.id);
|
|
845
|
+
if (!inbox) throw new Error('the forum did not say where to send it');
|
|
846
|
+
await pod.resend({ actor: podAcct.actor, podHome: podAcct.podHome, id, inbox });
|
|
847
|
+
say('Sent again. It shows here once the forum has it.');
|
|
848
|
+
sent = { id, until: Date.now() + SENT_WAIT_MS };
|
|
849
|
+
forgetIndex();
|
|
850
|
+
freshRead = true;
|
|
851
|
+
try { await route(); } finally { freshRead = false; }
|
|
852
|
+
} catch (e) { btn.disabled = false; alert(e.message); }
|
|
991
853
|
}
|
|
992
854
|
|
|
993
855
|
async function report(id) {
|
|
@@ -1287,18 +1149,15 @@ async function modBan(who, slug) {
|
|
|
1287
1149
|
// something says it, and the address it was read from otherwise.
|
|
1288
1150
|
async function paintMods(ids) {
|
|
1289
1151
|
if (!ids.length) return;
|
|
1290
|
-
|
|
1291
|
-
const named = await Promise.all(ids.map(async (id) => {
|
|
1292
|
-
const card = cat ? await authorOnce(cat.base, id).catch(() => null) : null;
|
|
1293
|
-
const handle = card?.handle || await modLabel(id);
|
|
1294
|
-
return `<a href="#/who/${encodeURIComponent(id)}">${esc(handle)}</a>`;
|
|
1295
|
-
}));
|
|
1296
|
-
$('mods-who').innerHTML = `<span class="lead">moderators:</span> ${named.join(', ')}`;
|
|
1152
|
+
$('mods-who').innerHTML = draw.modsWho(await Promise.all(ids.map(async (id) => ({ id, handle: await modLabel(id) }))));
|
|
1297
1153
|
}
|
|
1298
1154
|
|
|
1299
|
-
// What to call a moderator:
|
|
1300
|
-
// own word for it, and a name read off
|
|
1155
|
+
// What to call a moderator: what the drawn page called them, the card the
|
|
1156
|
+
// forum kept of them, then the actor's own word for it, and a name read off
|
|
1157
|
+
// the address only when none of those answers.
|
|
1158
|
+
const namedInPage = new Map((boot?.names || []).map(n => [n.id, n.handle]));
|
|
1301
1159
|
async function modLabel(actorId) {
|
|
1160
|
+
if (namedInPage.has(actorId)) return namedInPage.get(actorId);
|
|
1302
1161
|
const cat = cats()[0];
|
|
1303
1162
|
const card = cat ? await authorOnce(cat.base, actorId) : null;
|
|
1304
1163
|
if (card?.handle) return card.handle;
|
|
@@ -1589,6 +1448,7 @@ $('reply-send').addEventListener('click', async () => {
|
|
|
1589
1448
|
$('reply-text').value = '';
|
|
1590
1449
|
$('topic-title').value = '';
|
|
1591
1450
|
$('reply-dlg').close();
|
|
1451
|
+
if (!editing && (made?.uri || made?.url)) sent = { id: made.uri || made.url, until: Date.now() + SENT_WAIT_MS };
|
|
1592
1452
|
forgetIndex();
|
|
1593
1453
|
freshRead = true;
|
|
1594
1454
|
try { await route(); } finally { freshRead = false; }
|
|
@@ -1596,6 +1456,16 @@ $('reply-send').addEventListener('click', async () => {
|
|
|
1596
1456
|
$('reply-send').disabled = false;
|
|
1597
1457
|
});
|
|
1598
1458
|
|
|
1459
|
+
// Back to where the reader was before signing in, with the sign-in's own part
|
|
1460
|
+
// of the address gone, so a reload does not replay it.
|
|
1461
|
+
function goBack(query) {
|
|
1462
|
+
const back = sessionStorage.getItem('bb:return') || '';
|
|
1463
|
+
sessionStorage.removeItem('bb:return');
|
|
1464
|
+
const cut = back.indexOf('#');
|
|
1465
|
+
const [path, hash] = back.startsWith('/') ? [cut < 0 ? back : back.slice(0, cut), cut < 0 ? '' : back.slice(cut)] : [location.pathname, back];
|
|
1466
|
+
history.replaceState(null, '', `${path}${query ? '?' + query : ''}${hash}`);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1599
1469
|
// Back from the reader's server with a code: finish the sign-in, then clean
|
|
1600
1470
|
// the address so a reload does not replay it.
|
|
1601
1471
|
(async () => {
|
|
@@ -1622,10 +1492,7 @@ $('reply-send').addEventListener('click', async () => {
|
|
|
1622
1492
|
try { localStorage.setItem('bb:acct', JSON.stringify(who)); } catch { /* blocked storage */ }
|
|
1623
1493
|
sessionStorage.removeItem('bb:pod');
|
|
1624
1494
|
params.delete('code'); params.delete('state'); params.delete('iss');
|
|
1625
|
-
|
|
1626
|
-
const back = sessionStorage.getItem('bb:return') || '';
|
|
1627
|
-
sessionStorage.removeItem('bb:return');
|
|
1628
|
-
history.replaceState(null, '', `${location.pathname}${q ? '?' + q : ''}${back}`);
|
|
1495
|
+
goBack(String(params));
|
|
1629
1496
|
}
|
|
1630
1497
|
} catch (e) { $('reply-err').textContent = e.message; }
|
|
1631
1498
|
}
|
|
@@ -1635,15 +1502,32 @@ $('reply-send').addEventListener('click', async () => {
|
|
|
1635
1502
|
try {
|
|
1636
1503
|
await login.complete({ state, code });
|
|
1637
1504
|
params.delete('code'); params.delete('state');
|
|
1638
|
-
|
|
1639
|
-
sessionStorage.removeItem('bb:return');
|
|
1640
|
-
const q = String(params);
|
|
1641
|
-
history.replaceState(null, '', `${location.pathname}${q ? '?' + q : ''}${back}`);
|
|
1505
|
+
goBack(String(params));
|
|
1642
1506
|
} catch (e) { $('reply-err').textContent = e.message; }
|
|
1643
1507
|
}
|
|
1644
1508
|
// Sign-up lives at the Gateway, which is this page's own site or the one it is a face of.
|
|
1645
1509
|
$('fedipod-signup').href = front + '/new-account';
|
|
1646
|
-
|
|
1510
|
+
// A link to a post on the page being read moves to it; anything else is a
|
|
1511
|
+
// view of its own.
|
|
1512
|
+
window.addEventListener('hashchange', () => { if (viewKey() !== routed) route(); });
|
|
1513
|
+
window.addEventListener('popstate', () => route());
|
|
1514
|
+
// At the forum's own host, going between the front page and a category is
|
|
1515
|
+
// a different filter on what the page already holds: no load and no reads,
|
|
1516
|
+
// as long as it holds it.
|
|
1517
|
+
if (place.root) {
|
|
1518
|
+
document.addEventListener('click', (e) => {
|
|
1519
|
+
const a = e.target.closest?.('a[href]');
|
|
1520
|
+
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
1521
|
+
const to = new URL(a.getAttribute('href'), location.href);
|
|
1522
|
+
if (to.origin !== location.origin || to.search || to.hash) return;
|
|
1523
|
+
const v = viewOfPath(to.pathname, place.root);
|
|
1524
|
+
const filtering = to.pathname === place.root || v.kind === 'cat';
|
|
1525
|
+
if (!filtering || !indexHolds(index, Math.min(300, shown * 5))) return;
|
|
1526
|
+
e.preventDefault();
|
|
1527
|
+
history.pushState(null, '', to.pathname);
|
|
1528
|
+
route();
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1647
1531
|
await load();
|
|
1648
1532
|
// Whatever the reader pressed before being asked to sign in. After `load`,
|
|
1649
1533
|
// because voting and joining both need the categories to be known.
|