fedipod-bb 0.4.4 → 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/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
- // #/ #/c/<slug> #/t/<slug>/<tid>
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, sentNotListed } 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
25
- const when = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10); };
26
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, redirectUri: returnAddress(location.href) });
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
- // A thread in the shape it was written: each reply under the post it answers,
119
- // and anything whose parent is not here at the top level. Depth is capped so
120
- // a long argument does not walk off the side of the page.
121
- const MAX_DEPTH = 6;
122
- async function threaded(posts, authors, cat) {
123
- const byId = new Map(posts.map(p => [p.id, p]));
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
- // Voting, both ways, as a forum has always had it: up, down, and the one you
154
- // cast again to take it back. A reader with no account sees the counts.
155
- function votes(p) {
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)}/&lt;forum&gt;/</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
- const mods = (forum.admins || []).map(a => `<a href="#/who/${encodeURIComponent(a.id)}">${esc(a.handle)}</a>`).join(', ');
222
- $('mods-who').innerHTML = mods ? `<span class="lead">moderators:</span> ${mods}` : '';
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
- // One stop is no trail: the front page needs no line saying it is the
228
- // front page.
229
- if (parts.length < 2) { $('crumbs').textContent = ''; $('crumbs').hidden = true; return; }
230
- $('crumbs').hidden = false;
231
- $('crumbs').innerHTML = parts.map(([label, href], i) => (href && i < parts.length - 1 ? `<a href="${esc(href)}">${esc(label)}</a>` : esc(label))).join(' › ');
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
- $('mods-acts').innerHTML = '';
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
- if (kind === 'c' && a) { if (feedFilter !== a) shown = 30; feedFilter = a; return showForum(); }
260
- if (kind === 't' && a && b) return showTopic(a, b, c || null);
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', '#/'], ['Replies to you', null]]);
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 ? `#/t/${esc(cat.slug)}/${esc(tid)}/${esc(await cacheKey(p.id))}` : '#/';
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', '#/'], ['Bookmarked', null]]);
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 ? `#/t/${esc(cat.slug)}/${esc(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', '#/'], ['Queue', null]]);
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', '#/'], ['Someone', null]]);
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', '#/'], [card?.handle || authorLabel(handleOrId), null]]);
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 ? `#/t/${esc(c.slug)}/${esc(tid)}/${esc(await cacheKey(p.id))}` : '#/';
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', '#/'], ['Settings', null]]);
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; }
@@ -472,6 +415,9 @@ function forgetIndex() { index = null; authors.clear(); }
472
415
  // minute, instead of being kept for a minute without it.
473
416
  const SENT_EVERY_MS = 5_000;
474
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;
475
421
  let sent = null; // { id, until }
476
422
  const waitingOnSent = (idx) => sentNotListed(sent, idx?.latest);
477
423
  const indexHolds = (idx, want) => !!idx && idx.limit >= want
@@ -496,224 +442,109 @@ function digestOnce(id) {
496
442
 
497
443
  async function readIndex(want) {
498
444
  if (indexHolds(index, want)) return index;
499
- const latest = await read.latest(base, { limit: want });
500
- const site = new Set(await read.featured(base));
501
- const pinned = new Map();
502
- const mods = new Map();
503
- for (const c of cats()) {
504
- pinned.set(c.id, new Set(await read.featured(c.base)));
505
- mods.set(c.id, await read.moderators(c.base));
506
- }
507
- index = { at: Date.now(), limit: want, latest, site, pinned, mods };
445
+ index = await draw.readIndex(read, base, cats(), want);
508
446
  return index;
509
447
  }
510
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
+
511
457
  async function showForum() {
512
458
  const mineView = view;
513
459
  const here = cats().find(c => c.slug === feedFilter) || null;
514
- crumbs(here ? [['Home', '#/'], [here.name, `#/c/${here.slug}`]] : [['Home', '#/']]);
515
- // Who moderates what is on screen. Showing the whole forum, that is
516
- // whoever moderates its categories — otherwise a moderator looking at
517
- // everything would look like a stranger.
518
- // Moderators come out of the index below, which holds them for every
519
- // category — so this no longer decides what to fetch, only what to show.
520
- const showMods = (idx) => {
521
- if (here) { moderators = idx.mods.get(here.id) || []; } else {
522
- const all = new Set();
523
- for (const list of idx.mods.values()) for (const m of list) all.add(m);
524
- moderators = [...all];
525
- }
526
- // The line under the rule says who they are. It is painted from the
527
- // forum's own list when the page loads; this keeps it with the categories'
528
- // lists, which is where a moderator added while you read appears first.
529
- paintMods(moderators);
530
- };
531
- const chip = (slug, label) => `<a class="chip${feedFilter === slug ? ' on' : ''}"${feedFilter === slug ? ' aria-current="page"' : ''} href="${slug === 'all' ? '#/' : `#/c/${esc(slug)}`}">${esc(label)}</a>`;
532
- // Starting a topic from the front page: in the category being shown, or the
533
- // first one when the whole forum is.
534
- const into = cats().find(c => c.slug === feedFilter) || cats()[0] || null;
535
- // Built where it is drawn, not once above: whether this reader moderates is
536
- // read with the index below, and a row composed before that always said no.
537
- const head = () => `<p class="hint chips">Categories: ${chip('all', 'All')}${cats().map(c => chip(c.slug || '', c.name)).join('')}
538
- ${account() ? '<a class="chip" href="#/replies">Replies to you</a><a class="chip" href="#/bookmarks">Bookmarked</a>' : ''}
539
- ${iModerate() ? '<a class="chip" href="#/queue">Queue</a><a class="chip" href="#/settings">Settings</a>' : ''}
540
- <span class="rowend">
541
- ${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>` : ''}
542
- </span></p>`;
543
- // Search and New topic ride at the right end of the moderators row, which is
544
- // the one line the page keeps above whatever is being read.
545
- const acts = () => `<label class="vh" for="find">Search this forum</label>
546
- <input type="text" id="find" placeholder="Search" value="${esc(finding)}" autocomplete="off"${finding ? '' : ' hidden'}>
547
- <button data-act="search"${finding ? ' hidden' : ''}>Search</button>
548
- ${into ? `<button data-act="newtopic" data-slug="${esc(into.slug || '')}">New topic</button>` : ''}`;
549
- 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);
550
465
  // Enough posts to fill a page of TOPICS: several posts can belong to one.
551
466
  const want = Math.min(300, shown * 5);
552
467
  // Only say "Loading" when something is actually going to be waited for. The
553
468
  // reader changing category has everything already; blanking the page for
554
469
  // them was the whole of the delay they could see.
555
- const willFetch = !indexHolds(index, want);
556
- if (willFetch && !waitingOnSent(index)) {
557
- $('mods-acts').innerHTML = acts();
558
- $('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>';
559
473
  say('Loading the latest posts');
560
474
  }
561
475
  const idx = await readIndex(want);
562
476
  if (mineView !== view) return;
563
477
  if (waitingOnSent(idx)) setTimeout(() => { if (mineView === view) showForum(); }, SENT_EVERY_MS);
564
- showMods(idx);
565
- const latest = idx.latest;
566
- const looking = finding.trim().toLowerCase();
567
- const mine = latest.filter(p => (feedFilter === 'all'
568
- || (cats().find(c => c.id === p.category)?.slug === feedFilter))
569
- // Searching what the page has: a topic's name, an author's handle, and
570
- // the words of the post itself.
571
- && (!looking || [p.topicName, p.author, p.content].some(v => String(v || '').toLowerCase().includes(looking))));
572
- // Pinned topics first. A pin belongs to a category; the forum has a
573
- // featured collection of its own for a pin that holds everywhere.
574
- const pins = { cat: new Set(), site: idx.site };
575
- for (const c of cats()) {
576
- if (feedFilter !== 'all' && c.slug !== feedFilter) continue;
577
- for (const id of idx.pinned.get(c.id) || []) pins.cat.add(id);
578
- }
579
- // One row per topic: the newest post in it stands for it, which is what a
580
- // reader wants to open — the thing they have not read.
581
- const byTopic = new Map();
582
- for (const p of mine) {
583
- if (!p.topic) continue;
584
- const held = byTopic.get(p.topic);
585
- if (!held || String(p.published || '') > String(held.published || '')) byTopic.set(p.topic, p);
586
- }
587
- const rank = (p) => (pins.site.has(p.topic) ? 2 : pins.cat.has(p.topic) ? 1 : 0);
588
- // Pinned first whatever the order; then the column the reader chose.
589
- // Posts, not replies: a topic with nobody answering yet still HAS the post
590
- // that started it, and a column reading 0 beside a topic you can open and
591
- // read says the wrong thing. The count the forum keeps is of replies, so the
592
- // opening post is the one added here.
593
- const by = (p) => (order === 'replies' ? (p.topicReplies || 0) + 1 : String(p.published || ''));
594
- const topics = [...byTopic.values()].sort((a, b) => rank(b) - rank(a)
595
- || (down ? (by(a) > by(b) ? -1 : by(a) < by(b) ? 1 : 0) : (by(a) > by(b) ? 1 : by(a) < by(b) ? -1 : 0)));
596
- const page = topics.slice(0, shown);
597
- if (!topics.length) {
598
- // A members-only category that will not answer this reader looks exactly
599
- // like an empty one. Ask it a question only a member can have answered.
600
- if (here && !(await read.topics(here.base)).total && !(await read.canRead(here.base))) {
601
- if (mineView !== view) return;
602
- $('mods-acts').innerHTML = acts();
603
- $('main').innerHTML = head() + (podAcct
604
- ? `<p class="empty row">This category is for its members.
605
- <button class="new" data-act="ask-join" data-cat="${esc(here.id)}">Request membership</button></p>`
606
- : `<p class="empty row">This category is for its members, and membership needs a FediPod account.
607
- <a class="big" href="${esc(front)}/new-account">Get a FediPod account</a></p>`);
608
- say('This category is for its members');
609
- return;
610
- }
611
- // The one thing to do on an empty page is the one thing offered.
612
- if (mineView !== view) return;
613
- $('mods-acts').innerHTML = acts();
614
- $('main').innerHTML = head() + '<p class="empty">Nothing posted here yet. Use the button at the upper right to create a topic.</p>';
615
- say('Nothing posted here yet');
616
- return;
617
- }
618
- const rows = [];
619
- let fresh = 0;
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));
620
491
  // Every row's author and digest at once rather than one row at a time: they
621
492
  // do not depend on each other, and waiting for each in turn is the whole of
622
493
  // the delay between choosing a category and seeing it.
623
- const looked = await Promise.all(page.map(async (p) => {
624
- const cat = cats().find(c => c.id === p.category) || null;
625
- return {
626
- cat,
627
- who: cat && p.author ? await authorOnce(cat.base, p.author) : null,
628
- digest: await digestOnce(p.id),
629
- };
630
- }));
494
+ const facts = await draw.rowFacts(rows.page, cats(), authorOnce, digestOnce);
631
495
  if (mineView !== view) return;
632
- for (let i = 0; i < page.length; i++) {
633
- const p = page[i];
634
- const { cat, who, digest } = looked[i];
635
- const tid = p.topic ? p.topic.split('/').pop() : null;
636
- const href = cat && tid ? `#/t/${esc(cat.slug)}/${esc(tid)}/${esc(digest)}` : null;
637
- const name = esc(p.topicName || 'Topic');
638
- // New to THIS reader: posted since they last had the topic open.
639
- const isNew = !!seen?.isNew(p.topic, p.published);
640
- if (isNew) fresh += 1;
641
- const byline = who ? who.handle : (p.author ? authorLabel(p.author) : '');
642
- rows.push(`<tr>
643
- <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
644
- ? ' <span class="badge">New<span class="vh"> since you last opened this topic</span></span>' : ''}</td>
645
- <td>${cat ? esc(cat.name) : ''}</td>
646
- <td>${byline ? (p.author
647
- ? `<a class="who" href="#/who/${encodeURIComponent(p.author)}" title="${esc(byline)}">${esc(byline)}</a>`
648
- : `<span class="who" title="${esc(byline)}">${esc(byline)}</span>`) : ''}</td>
649
- <td>${esc(when(p.published))}</td>
650
- <td>${Number.isFinite(p.topicReplies) ? p.topicReplies + 1 : ''}</td>
651
- </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;
652
503
  }
653
- // A table, so a reader can run their eye down any one of the four things
654
- // an entry says.
655
- const more = Math.max(0, topics.length - page.length) + (latest.more || 0);
656
- if (mineView !== view) return;
657
- $('mods-acts').innerHTML = acts();
658
- $('main').innerHTML = head() + `<div class="scroll" role="region" aria-label="Topics, most recently posted in first" tabindex="0"><table class="index">
659
- <thead><tr><th scope="col">Topic</th><th scope="col">Category</th><th scope="col">Latest by</th>
660
- <th scope="col" aria-sort="${order === 'date' ? (down ? 'descending' : 'ascending') : 'none'}">
661
- <button class="sort" data-act="order" data-order="date">Date ${order === 'date' ? (down ? '▼' : '▲') : '<span class="dim">▽</span>'}</button></th>
662
- <th scope="col" aria-sort="${order === 'replies' ? (down ? 'descending' : 'ascending') : 'none'}">
663
- <button class="sort" data-act="order" data-order="replies">Posts ${order === 'replies' ? (down ? '▼' : '▲') : '<span class="dim">▽</span>'}</button></th></tr></thead>
664
- <tbody>${rows.join('')}</tbody></table>
665
- ${more ? `<p class="row"><button data-act="more">Show ${Math.min(more, 30)} more of ${more}</button></p>` : ''}</div>`;
666
- say(`${rows.length} topic${rows.length === 1 ? '' : 's'}${fresh ? `, ${fresh} with something new` : ''}`);
667
504
  // The box takes the keyboard only when there is something in it to scroll:
668
505
  // a tab stop that does nothing is one more press between a reader and the page.
669
- const box = $('main').querySelector('.scroll');
670
- if (box) { if (box.scrollHeight > box.clientHeight) box.tabIndex = 0; else box.removeAttribute('tabindex'); }
506
+ const scroller = $('main').querySelector('.scroll');
507
+ if (scroller) { if (scroller.scrollHeight > scroller.clientHeight) scroller.tabIndex = 0; else scroller.removeAttribute('tabindex'); }
671
508
  }
672
509
 
673
-
674
510
  async function showTopic(slug, tid, atPost = null) {
511
+ const drawn = drawnFor('topic', slug, tid);
512
+ const keep = first && asDrawn(drawn);
513
+ first = false;
675
514
  const cat = catBySlug(slug);
676
515
  if (!cat) { $('main').innerHTML = '<p class="err">No such category.</p>'; return; }
677
516
  const topicId = cat.base + 'ap/topic/' + tid;
678
- moderators = await read.moderators(cat.base);
679
- crumbs([['Home', '#/'], [cat.name, `#/c/${slug}`], ['topic', null]]);
680
- $('main').innerHTML = '<p class="dim">Loading…</p>';
681
- say('Loading the topic');
682
- const t = await read.topic(topicId);
683
- if (!t) { $('main').innerHTML = '<p class="err">No such topic.</p>'; return; }
684
- crumbs([['Home', '#/'], [cat.name, `#/c/${slug}`], [t.name, null]]);
685
- const posts = [];
686
- for (const id of t.posts) posts.push({ id, ...(await read.post(cat.base, id)) });
687
- const authors = new Map();
688
- for (const p of posts) {
689
- if (!p.author || authors.has(p.author)) continue;
690
- 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);
691
523
  }
524
+ moderators = got.moderators || [];
525
+ const { t, posts = [] } = got;
692
526
  // Opening a topic reads it, up to the newest post that was in it.
693
- seen?.markRead(topicId, seen.newest(posts));
694
- const placed = new Set(t.posts);
695
- const pending = waiting(topicId).filter(w => !placed.has(w.id));
696
- $('main').innerHTML = `<div class="topline"><h1>${esc(t.name)}</h1>
697
- <button class="primary" data-act="newpost" data-topic="${esc(topicId)}">New post</button></div>
698
- ${topicActs(topicId)}
699
-
700
- ${(await threaded(posts, authors, cat)).join('')}
701
- ${pending.map(w => card({ author: w.author, published: w.at, content: toHtml(w.text) },
702
- { cat, extra: ' waiting', waiting: true })).join('')}`;
703
- replyBox({ cat, title: 'Reply', inReplyToUrl: t.posts[t.posts.length - 1] || null, topicId });
704
- $('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) })) : [];
705
534
  // A closed topic takes nothing more: the forum would refuse it anyway, so
706
- // the page does not offer it.
707
- if (t.closed) {
708
- for (const b of document.querySelectorAll('[data-act="newpost"], [data-act="reply"]')) b.remove();
709
- const said = document.createElement('p');
710
- said.className = 'hint';
711
- said.textContent = 'This topic is closed. No more replies are being taken.';
712
- 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;
713
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
714
546
  // Arriving from the front page: the post that was linked to, in view and
715
547
  // marked, rather than the top of a thread it sits somewhere inside.
716
- say(`${t.name}, ${posts.length} post${posts.length === 1 ? '' : 's'}`);
717
548
  if (atPost) {
718
549
  const el = document.getElementById('p-' + atPost);
719
550
  // Taken to, not just scrolled to: a keyboard and a screen reader both land
@@ -755,8 +586,8 @@ async function signIn(handleInput, noteId = 'fedi-note') {
755
586
  if (/^https?:\/\//u.test(typed)) {
756
587
  $(noteId).textContent = 'Asking your pod…';
757
588
  try {
758
- const { authorizationUrl, storageRoot } = await pod.signInWithWebId(typed, location.origin + location.pathname);
759
- sessionStorage.setItem('bb:return', location.hash);
589
+ const { authorizationUrl, storageRoot } = await pod.signInWithWebId(typed, signInAddress);
590
+ sessionStorage.setItem('bb:return', whereNow());
760
591
  sessionStorage.setItem('bb:pod', JSON.stringify({ handle: authorLabel(typed), actor: typed, podHome: storageRoot }));
761
592
  location.href = authorizationUrl;
762
593
  } catch (e) { $(noteId).textContent = e.message; }
@@ -770,7 +601,7 @@ async function signIn(handleInput, noteId = 'fedi-note') {
770
601
  const said = await serverKind(host, front, undefined, handle);
771
602
  const kind = said.kind;
772
603
  if (kind === 'mastodon-api') {
773
- sessionStorage.setItem('bb:return', location.hash);
604
+ sessionStorage.setItem('bb:return', whereNow());
774
605
  location.href = await login.begin(host);
775
606
  return;
776
607
  }
@@ -778,9 +609,9 @@ async function signIn(handleInput, noteId = 'fedi-note') {
778
609
  note.textContent = '';
779
610
  if (kind === 'fedipod') {
780
611
  if (!said.issuer || !said.actor) throw new Error(`${host} did not say where ${handleInput} signs in`);
781
- sessionStorage.setItem('bb:return', location.hash);
612
+ sessionStorage.setItem('bb:return', whereNow());
782
613
  sessionStorage.setItem('bb:pod', JSON.stringify({ handle: `@${handle}@${host}`, actor: said.actor, podHome: said.podHome }));
783
- location.href = await pod.signIn({ issuer: said.issuer, redirectUri: location.origin + location.pathname });
614
+ location.href = await pod.signIn({ issuer: said.issuer, redirectUri: signInAddress });
784
615
  return;
785
616
  }
786
617
  note.textContent = kind === 'lemmy'
@@ -853,6 +684,7 @@ $('main').addEventListener('click', async (e) => {
853
684
  if (act === 'share') return share(id, b);
854
685
  if (act === 'report') return report(id);
855
686
  if (act === 'remove') return modRemove(id);
687
+ if (act === 'resend') return resendPost(id, b);
856
688
  const topic = b.dataset.topic || b.closest('[data-topic]')?.dataset.topic;
857
689
  if (act === 'more') { shown += 30; return showForum(); }
858
690
  if (act === 'order') {
@@ -985,8 +817,9 @@ async function openEdit(id) {
985
817
  // The address of this post on this site, for pasting anywhere. The thread is
986
818
  // what a person wants to open, and the post is named in it.
987
819
  async function share(id, btn) {
988
- const hash = location.hash.startsWith('#/t/') ? location.hash : `#/t/${replyCtx.cat.slug}/${(await topicOf(id)) || ''}`;
989
- const link = `${location.origin}${location.pathname}${hash}`;
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}`;
990
823
  try { await navigator.clipboard.writeText(link); btn.textContent = 'Copied'; say('Link copied'); }
991
824
  catch { window.prompt('Copy this address', link); return; }
992
825
  setTimeout(() => { btn.textContent = 'Share'; }, 1500);
@@ -996,8 +829,27 @@ async function share(id, btn) {
996
829
  // thread: the card sits inside the topic's own list item.
997
830
  async function topicOf(id) {
998
831
  const li = [...document.querySelectorAll('ul.list li')].find(el => el.querySelector(`[data-post="${CSS.escape(id)}"]`));
999
- const a = li?.querySelector('a[href^="#/t/"]');
1000
- return a ? a.getAttribute('href').split('/').pop() : null;
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); }
1001
853
  }
1002
854
 
1003
855
  async function report(id) {
@@ -1297,18 +1149,15 @@ async function modBan(who, slug) {
1297
1149
  // something says it, and the address it was read from otherwise.
1298
1150
  async function paintMods(ids) {
1299
1151
  if (!ids.length) return;
1300
- const cat = cats()[0];
1301
- const named = await Promise.all(ids.map(async (id) => {
1302
- const card = cat ? await authorOnce(cat.base, id).catch(() => null) : null;
1303
- const handle = card?.handle || await modLabel(id);
1304
- return `<a href="#/who/${encodeURIComponent(id)}">${esc(handle)}</a>`;
1305
- }));
1306
- $('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) }))));
1307
1153
  }
1308
1154
 
1309
- // What to call a moderator: the card the forum kept of them, then the actor's
1310
- // own word for it, and a name read off the address only when neither answers.
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]));
1311
1159
  async function modLabel(actorId) {
1160
+ if (namedInPage.has(actorId)) return namedInPage.get(actorId);
1312
1161
  const cat = cats()[0];
1313
1162
  const card = cat ? await authorOnce(cat.base, actorId) : null;
1314
1163
  if (card?.handle) return card.handle;
@@ -1607,6 +1456,16 @@ $('reply-send').addEventListener('click', async () => {
1607
1456
  $('reply-send').disabled = false;
1608
1457
  });
1609
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
+
1610
1469
  // Back from the reader's server with a code: finish the sign-in, then clean
1611
1470
  // the address so a reload does not replay it.
1612
1471
  (async () => {
@@ -1633,10 +1492,7 @@ $('reply-send').addEventListener('click', async () => {
1633
1492
  try { localStorage.setItem('bb:acct', JSON.stringify(who)); } catch { /* blocked storage */ }
1634
1493
  sessionStorage.removeItem('bb:pod');
1635
1494
  params.delete('code'); params.delete('state'); params.delete('iss');
1636
- const q = String(params);
1637
- const back = sessionStorage.getItem('bb:return') || '';
1638
- sessionStorage.removeItem('bb:return');
1639
- history.replaceState(null, '', `${location.pathname}${q ? '?' + q : ''}${back}`);
1495
+ goBack(String(params));
1640
1496
  }
1641
1497
  } catch (e) { $('reply-err').textContent = e.message; }
1642
1498
  }
@@ -1646,15 +1502,32 @@ $('reply-send').addEventListener('click', async () => {
1646
1502
  try {
1647
1503
  await login.complete({ state, code });
1648
1504
  params.delete('code'); params.delete('state');
1649
- const back = sessionStorage.getItem('bb:return') || '';
1650
- sessionStorage.removeItem('bb:return');
1651
- const q = String(params);
1652
- history.replaceState(null, '', `${location.pathname}${q ? '?' + q : ''}${back}`);
1505
+ goBack(String(params));
1653
1506
  } catch (e) { $('reply-err').textContent = e.message; }
1654
1507
  }
1655
1508
  // Sign-up lives at the Gateway, which is this page's own site or the one it is a face of.
1656
1509
  $('fedipod-signup').href = front + '/new-account';
1657
- window.addEventListener('hashchange', () => route());
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
+ }
1658
1531
  await load();
1659
1532
  // Whatever the reader pressed before being asked to sign in. After `load`,
1660
1533
  // because voting and joining both need the categories to be known.