fedipod 1.55.2 → 1.57.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 +1 -1
- package/gui.md +10 -0
- package/lib/client/masto/accounts.mjs +1 -1
- package/lib/client/masto/timelines.mjs +11 -2
- package/lib/gateway/forum-pages.mjs +59 -0
- package/lib/gateway/front-core.mjs +2 -1
- package/lib/surface/routes/owner.mjs +16 -0
- package/package.json +3 -4
- package/scripts/build-app.mjs +3 -0
- package/scripts/check-netlify-bundles.mjs +3 -0
- package/scripts/forum-package.mjs +26 -0
- package/scripts/stage-site.mjs +32 -10
- package/web/admin/record.js +39 -1
- package/web/app/admin-facade.mjs +18 -1
- package/web/app/dist/sw.js +28 -4
- package/web/app/dist/sw.js.map +2 -2
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ This README is about the `FediPod` as used with a Gateway. There are also a numb
|
|
|
31
31
|
You can access your account by pointing any Mastodon API or C2S client at fedipod.net and signing in. There are also two built-inclients, both served by fedipod.net: [Sengi](https://github.com/NicolasConstant/sengi) (MIT, by Nicolas Constant), which you get by default, and [Phanpy](https://github.com/cheeaun/phanpy)(MIT, by Chee Aun). The links at the top right of every page switch between
|
|
32
32
|
them, at any time; whichever you opened last is the one that opens next time.
|
|
33
33
|
|
|
34
|
-
You can follow and unfollow, post, reply, favourite, boost, attach media, edit and delete. Content warnings, polls with up to four options and one or several answers, all four visibility levels, a conversations view for direct messages, bookmarks, lists, keyword filters, pinned posts that other servers can see, blocking and muting, and the custom emojis other servers send.
|
|
34
|
+
You can follow and unfollow, post, reply, favourite, boost, attach media, edit and delete. Content warnings, polls with up to four options and one or several answers, all four visibility levels, a conversations view for direct messages, bookmarks, lists (a list can keep its people out of your home timeline), keyword filters, pinned posts that other servers can see, blocking and muting, and the custom emojis other servers send.
|
|
35
35
|
|
|
36
36
|
A post can quote another post. Quote is offered on posts whose author allows
|
|
37
37
|
it; the quoted author's server is asked, and the quote shows as pending until
|
package/gui.md
CHANGED
|
@@ -104,6 +104,16 @@ that one of your connected accounts also brought in is from someone you follow
|
|
|
104
104
|
there, and stays. An app already showing them keeps them on screen until it
|
|
105
105
|
reloads.
|
|
106
106
|
|
|
107
|
+
## Lists in your home timeline
|
|
108
|
+
|
|
109
|
+
The **lists** row counts your lists, and under it each list has a switch:
|
|
110
|
+
*shown in home* or *hidden from home*. Hidden, the posts and boosts of the
|
|
111
|
+
people on that list leave your home timeline and show on the list's own
|
|
112
|
+
timeline and on the public timeline. Lists are made and filled in the app;
|
|
113
|
+
an app can set the same switch (in Phanpy, **Hide posts on this list from
|
|
114
|
+
Home/Following**). An app already showing the posts keeps them on screen
|
|
115
|
+
until it reloads.
|
|
116
|
+
|
|
107
117
|
## Handling follow requests
|
|
108
118
|
|
|
109
119
|
New followers are accepted automatically. Set **new followers** to *wait for
|
|
@@ -250,7 +250,7 @@ export async function handle(api, ctx) {
|
|
|
250
250
|
const actorUrl = api.store.urlFor(mAccLists[1]);
|
|
251
251
|
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
252
252
|
return send(200, api.store.getLists().filter(l => (l.members || []).includes(actorUrl))
|
|
253
|
-
.map(l => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive:
|
|
253
|
+
.map(l => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive: !!l.exclusive })));
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
// The people muted and blocked, as accounts; the domains blocked, as names.
|
|
@@ -22,6 +22,10 @@ export async function handle(api, ctx) {
|
|
|
22
22
|
// stay on the public timeline, their hashtags' pages and trends. One a
|
|
23
23
|
// connected account also brought in is from someone followed there.
|
|
24
24
|
const hideTags = pathname === '/api/v1/timelines/home' && api.store.getConfig?.()?.tagsInHome === false;
|
|
25
|
+
// A list set to hide its people from Home (`exclusive`): their posts and
|
|
26
|
+
// boosts show on the list and on the public timeline only.
|
|
27
|
+
const hiddenBy = new Set(pathname === '/api/v1/timelines/home'
|
|
28
|
+
? (api.store.getLists?.() || []).filter(l => l.exclusive).flatMap(l => l.members || []) : []);
|
|
25
29
|
// The UNFILTERED list, both as the source and as the reply-count corpus:
|
|
26
30
|
// a reply can be a mention, and mentions are filtered out of timelines
|
|
27
31
|
// below — counting against the filtered set would undercount them.
|
|
@@ -36,6 +40,7 @@ export async function handle(api, ctx) {
|
|
|
36
40
|
.filter(s => !s.direct && s.visibility !== 'direct')
|
|
37
41
|
.filter(s => !muted.has(s.actor) && !muted.has(s.via))
|
|
38
42
|
.filter(s => !hideTags || s.kind !== 'tag' || s.sourceAccts?.length)
|
|
43
|
+
.filter(s => !hiddenBy.size || !(hiddenBy.has(s.actor) || hiddenBy.has(s.via)))
|
|
39
44
|
.filter(s => !localOnly || (s.kind === 'post' && s.visibility !== 'private'))
|
|
40
45
|
.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
41
46
|
const { items: page, headers } = api.page(items, url);
|
|
@@ -122,7 +127,9 @@ export async function handle(api, ctx) {
|
|
|
122
127
|
|
|
123
128
|
// Lists, filters and scheduled posts are the client's own arrangements —
|
|
124
129
|
// kept in local state, nothing federates.
|
|
125
|
-
|
|
130
|
+
// `exclusive`: the list hides its people from Home. A form sends it as a word.
|
|
131
|
+
const flag = (v) => v === true || v === 'true' || v === 'on' || v === '1';
|
|
132
|
+
const listJson = (l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive: !!l.exclusive });
|
|
126
133
|
if (pathname === '/api/v1/lists' && req.method === 'GET') {
|
|
127
134
|
return send(200, api.store.getLists().map(listJson));
|
|
128
135
|
}
|
|
@@ -131,7 +138,8 @@ export async function handle(api, ctx) {
|
|
|
131
138
|
const title = String(body.title || '').trim();
|
|
132
139
|
if (!title) return send(422, { error: 'a title is required' });
|
|
133
140
|
const lists = api.store.getLists();
|
|
134
|
-
const l = { id: crypto.randomBytes(8).toString('hex'), title, repliesPolicy: body.replies_policy || 'list',
|
|
141
|
+
const l = { id: crypto.randomBytes(8).toString('hex'), title, repliesPolicy: body.replies_policy || 'list',
|
|
142
|
+
exclusive: flag(body.exclusive), members: [] };
|
|
135
143
|
lists.push(l);
|
|
136
144
|
api.store.setLists(lists);
|
|
137
145
|
return send(200, listJson(l));
|
|
@@ -149,6 +157,7 @@ export async function handle(api, ctx) {
|
|
|
149
157
|
const body = await readBody(req);
|
|
150
158
|
if (body.title) l.title = String(body.title);
|
|
151
159
|
if (body.replies_policy) l.repliesPolicy = body.replies_policy;
|
|
160
|
+
if ('exclusive' in body) l.exclusive = flag(body.exclusive);
|
|
152
161
|
api.store.setLists(lists);
|
|
153
162
|
}
|
|
154
163
|
return send(200, listJson(l));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// forum-pages.mjs — a forum's page at its own host (bb.<gateway>/<forum>/…).
|
|
2
|
+
// Every address under a forum is handed to the forum's own code
|
|
3
|
+
// (ctx.forum.renderPage, netlify/forum-support.mjs), which draws the ones it
|
|
4
|
+
// draws; for the rest, or when drawing fails, the page as the site serves it,
|
|
5
|
+
// which the reader's browser builds. Which addresses are drawn is the forum's
|
|
6
|
+
// business, not this file's.
|
|
7
|
+
|
|
8
|
+
// A forum's path, and anything under it.
|
|
9
|
+
const VIEW = /^\/([^/]+)\//u;
|
|
10
|
+
// First path segments the bb host gives to something other than a forum.
|
|
11
|
+
const NOT_FORUMS = new Set(['api', 'bb', 'icons', 'u', '.well-known']);
|
|
12
|
+
// How long the edge keeps a drawn page. The forum's documents are dropped
|
|
13
|
+
// from the edge when a post lands and when the forum runs, under the labels
|
|
14
|
+
// the page carries too, so an hour only bounds a change nothing announced.
|
|
15
|
+
// Kept per region, not durably: a page names the scripts of the deploy that
|
|
16
|
+
// drew it.
|
|
17
|
+
const PAGE_EDGE_SECONDS = 3600;
|
|
18
|
+
const MISSING_EDGE_SECONDS = 120;
|
|
19
|
+
|
|
20
|
+
// The page as the site serves it, read once per running instance: it changes
|
|
21
|
+
// only with a deploy, which starts new ones.
|
|
22
|
+
const shells = new Map();
|
|
23
|
+
async function shellOf(origin, f) {
|
|
24
|
+
if (shells.has(origin)) return shells.get(origin);
|
|
25
|
+
const r = await f(`${origin}/bb/index.html`).catch(() => null);
|
|
26
|
+
const text = r?.ok ? await r.text().catch(() => null) : null;
|
|
27
|
+
if (text) shells.set(origin, text);
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The answer for a forum's page at its own host, or null for any other request. */
|
|
32
|
+
export async function routeForumPage(request, ctx) {
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
if (!/^bb\./u.test(url.hostname)) return null;
|
|
35
|
+
const m = VIEW.exec(url.pathname);
|
|
36
|
+
if (!m || NOT_FORUMS.has(m[1])) return null;
|
|
37
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: { allow: 'GET, HEAD' }, body: '' };
|
|
38
|
+
const shell = await shellOf(url.origin, ctx.siteFetch || globalThis.fetch.bind(globalThis));
|
|
39
|
+
if (!shell) return { status: 503, headers: { 'content-type': 'text/plain', 'cache-control': 'no-store', 'retry-after': '30' }, body: 'the forum\'s page is not available right now\n' };
|
|
40
|
+
const page = (status, body, headers) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache', ...headers }, body });
|
|
41
|
+
// An address with a query is a sign-in coming back, or nothing a reader
|
|
42
|
+
// follows: the page builds it.
|
|
43
|
+
const draws = !!ctx.forum?.renderPage && !url.search;
|
|
44
|
+
// A drawing not done in time goes on after the page has gone, where the
|
|
45
|
+
// host lets work outlive its answer (ctx.waitUntil): the next reader's
|
|
46
|
+
// drawing then finds the forum's documents at hand.
|
|
47
|
+
const drawn = draws ? await ctx.forum.renderPage({ url: url.href, shell, keepGoing: ctx.waitUntil || null }).catch(() => null) : null;
|
|
48
|
+
if (drawn) {
|
|
49
|
+
const hold = drawn.status === 404 ? MISSING_EDGE_SECONDS : PAGE_EDGE_SECONDS;
|
|
50
|
+
return page(drawn.status, drawn.html, {
|
|
51
|
+
'netlify-cdn-cache-control': `public, s-maxage=${hold}, stale-while-revalidate=${hold * 4}`,
|
|
52
|
+
'netlify-cache-tag': drawn.tags.join(','),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Not drawn. Where drawing was tried, this answer is not kept, so the next
|
|
56
|
+
// reader gets a drawn page; where nothing here draws, it is the same page
|
|
57
|
+
// for everyone and is kept like one.
|
|
58
|
+
return page(200, shell, draws || url.search ? {} : { 'netlify-cdn-cache-control': `public, s-maxage=${PAGE_EDGE_SECONDS}` });
|
|
59
|
+
}
|
|
@@ -26,6 +26,7 @@ import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, clo
|
|
|
26
26
|
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
|
|
27
27
|
import { routeCheckApi, missingLetter } from './check-letter.mjs';
|
|
28
28
|
import { routeNoticesApi } from './notices.mjs';
|
|
29
|
+
import { routeForumPage } from './forum-pages.mjs';
|
|
29
30
|
import { withSecurityHeaders } from './headers.mjs';
|
|
30
31
|
import { claimedWebId, verifyPodToken } from './token-claims.mjs';
|
|
31
32
|
|
|
@@ -307,7 +308,7 @@ function identFor(rec, policy = null) {
|
|
|
307
308
|
// an adapter that reads a store directly and so
|
|
308
309
|
// must confine the read itself.
|
|
309
310
|
export async function routeFront(request, ctx) {
|
|
310
|
-
const out = await route(request, ctx);
|
|
311
|
+
const out = (await routeForumPage(request, ctx)) || await route(request, ctx);
|
|
311
312
|
return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
|
|
312
313
|
}
|
|
313
314
|
|
|
@@ -14,6 +14,9 @@ const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
|
14
14
|
// Config the actor document carries, so a change is not real until it is
|
|
15
15
|
// republished.
|
|
16
16
|
const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators', 'autoAcceptFollows'];
|
|
17
|
+
// A list as the page shows it: its name and whether its people are kept out
|
|
18
|
+
// of Home. Lists are made and filled in the client; the page only switches.
|
|
19
|
+
const listRow = (l) => ({ id: l.id, title: l.title, exclusive: !!l.exclusive });
|
|
17
20
|
|
|
18
21
|
export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
|
|
19
22
|
const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
|
|
@@ -71,6 +74,7 @@ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-u
|
|
|
71
74
|
aliases: cfg.aliases || [],
|
|
72
75
|
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
73
76
|
tagsInHome: cfg.tagsInHome !== false,
|
|
77
|
+
lists: agent.store.getLists().map(listRow),
|
|
74
78
|
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
75
79
|
// Per-machine, so it comes from the credential file, not pod config —
|
|
76
80
|
// and it is a fact here, not a setting: moving it means moving data,
|
|
@@ -254,6 +258,18 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
254
258
|
},
|
|
255
259
|
});
|
|
256
260
|
}
|
|
261
|
+
// One list's Home switch, the same `exclusive` a Mastodon app sets.
|
|
262
|
+
case '/lists': {
|
|
263
|
+
if (typeof body.exclusive !== 'boolean') return json(res, 400, { error: 'exclusive (true or false) is the one thing to set here' });
|
|
264
|
+
await agent.requestTakeover?.();
|
|
265
|
+
const lists = agent.store.getLists();
|
|
266
|
+
const l = lists.find(x => x.id === String(body.id || ''));
|
|
267
|
+
if (!l) return json(res, 404, { error: 'no such list' });
|
|
268
|
+
l.exclusive = body.exclusive;
|
|
269
|
+
agent.store.setLists(lists);
|
|
270
|
+
await agent.store.flush();
|
|
271
|
+
return json(res, 200, { ok: true, lists: lists.map(listRow) });
|
|
272
|
+
}
|
|
257
273
|
default: return false;
|
|
258
274
|
}
|
|
259
275
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.57.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"install-service": "node bin/fedipod.mjs install-service",
|
|
56
56
|
"uninstall-service": "node bin/fedipod.mjs uninstall-service",
|
|
57
57
|
"prepublishOnly": "npm test",
|
|
58
|
-
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
|
|
58
|
+
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs claude/smoke-tests/forum-pages.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
|
|
59
59
|
"verify-ui": "node scripts/verify-ui.mjs",
|
|
60
60
|
"build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
|
|
61
61
|
"build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
|
|
@@ -97,7 +97,6 @@
|
|
|
97
97
|
"package-lock.json": "npm:empty-npm-package@1.0.0"
|
|
98
98
|
},
|
|
99
99
|
"devDependencies": {
|
|
100
|
-
"esbuild": "^0.28.2"
|
|
101
|
-
"fedipod-bb": "^0.4.4"
|
|
100
|
+
"esbuild": "^0.28.2"
|
|
102
101
|
}
|
|
103
102
|
}
|
package/scripts/build-app.mjs
CHANGED
|
@@ -67,6 +67,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
67
67
|
const entry = args[args.indexOf('--entry') + 1] || path.join(root, 'web/app/agent.mjs');
|
|
68
68
|
const out = args[args.indexOf('--out') + 1] || path.join(root, 'web/app/dist/agent.js');
|
|
69
69
|
const format = args.includes('--format') ? args[args.indexOf('--format') + 1] : 'esm';
|
|
70
|
+
// The app's "make a forum" is built from the forum's own folder, as it is now.
|
|
71
|
+
const { bringForum } = await import('./forum-package.mjs');
|
|
72
|
+
bringForum();
|
|
70
73
|
const { bytes } = await buildApp({ entry, out, format });
|
|
71
74
|
console.log(`built ${path.relative(root, out)} — ${Math.round(bytes / 1024)} KB`);
|
|
72
75
|
}
|
|
@@ -18,6 +18,9 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
|
|
|
18
18
|
let fails = 0;
|
|
19
19
|
const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
|
|
20
20
|
try {
|
|
21
|
+
// The functions carry the forum as it is in its own folder now.
|
|
22
|
+
const { bringForum } = await import('./forum-package.mjs');
|
|
23
|
+
bringForum();
|
|
21
24
|
execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
|
|
22
25
|
{ cwd: root, stdio: 'ignore' });
|
|
23
26
|
const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// forum-package.mjs — the forum (fedipod-bb) that fedipod.net runs and
|
|
2
|
+
// serves, taken from the forum's own folder every time, not from a version
|
|
3
|
+
// FediPod pins: the checkout beside FediPod, or a clone of the forum's GitHub
|
|
4
|
+
// main where there is none (scripts/dependents.mjs). It is packed as npm would
|
|
5
|
+
// publish it and put in node_modules without touching FediPod's package list,
|
|
6
|
+
// so nothing FediPod records names a forum version. Staging the site, the
|
|
7
|
+
// bundle check and the app build each bring it first.
|
|
8
|
+
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { fetchDependent, root } from './dependents.mjs';
|
|
11
|
+
|
|
12
|
+
/** The forum from its folder, installed in node_modules. Returns its folder and version. */
|
|
13
|
+
export function bringForum({ log = console.log } = {}) {
|
|
14
|
+
const dir = fetchDependent('fedipod-bb', { log });
|
|
15
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forum-pack-'));
|
|
16
|
+
try {
|
|
17
|
+
const packed = JSON.parse(execFileSync('npm', ['pack', dir, '--pack-destination', tmp, '--ignore-scripts', '--json'],
|
|
18
|
+
{ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }))[0];
|
|
19
|
+
execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', '--ignore-scripts', path.join(tmp, packed.filename)],
|
|
20
|
+
{ cwd: root, stdio: 'ignore' });
|
|
21
|
+
log(`forum: fedipod-bb ${packed.version} from ${path.relative(root, dir) || dir}`);
|
|
22
|
+
return { dir, version: packed.version };
|
|
23
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (import.meta.url === `file://${process.argv[1]}`) bringForum();
|
package/scripts/stage-site.mjs
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
16
|
import { readyDependent } from './dependents.mjs';
|
|
17
|
+
import { bringForum } from './forum-package.mjs';
|
|
17
18
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
19
|
const site = path.join(root, 'web/app/site');
|
|
20
|
+
// The forum's page is copied from the forum's own folder, as it is now.
|
|
21
|
+
bringForum({ log: () => {} });
|
|
19
22
|
fs.rmSync(site, { recursive: true, force: true }); fs.mkdirSync(path.join(site, 'app'), { recursive: true });
|
|
20
23
|
const cp = (from, to) => fs.copyFileSync(path.resolve(root, from), path.join(site, to));
|
|
21
24
|
cp('web/app/index.html', 'index.html');
|
|
@@ -219,6 +222,12 @@ fs.writeFileSync(path.join(site, '_redirects'), [
|
|
|
219
222
|
`https://${h}/update.js /update.js 200!`,
|
|
220
223
|
`https://${h}/build.json /build.json 200!`,
|
|
221
224
|
`https://${h}/api/* /.netlify/functions/front 200!`,
|
|
225
|
+
// The forum's host answers /robots.txt with the forum's own.
|
|
226
|
+
`https://${h}/robots.txt /bb/robots.txt 200!`,
|
|
227
|
+
// Every address under a forum goes to the forum's code, which draws the
|
|
228
|
+
// ones it draws (lib/gateway/forum-pages.mjs); the rest get the page.
|
|
229
|
+
`https://${h}/:forum/ /.netlify/functions/front 200!`,
|
|
230
|
+
`https://${h}/:forum/* /.netlify/functions/front 200!`,
|
|
222
231
|
`https://${h}/* /bb/index.html 200!`,
|
|
223
232
|
]),
|
|
224
233
|
'# the in-browser app, served static (these win over the project catch-all)',
|
|
@@ -267,7 +276,13 @@ fs.writeFileSync(path.join(site, '_redirects'), [
|
|
|
267
276
|
// own /u/<handle>/ or a pod named outright. Its script is a module file of
|
|
268
277
|
// its own, so it is served under the same `script-src 'self'` as the app.
|
|
269
278
|
fs.mkdirSync(path.join(site, 'bb'), { recursive: true });
|
|
270
|
-
|
|
279
|
+
// Every file the forum's page ships, whatever the forum names them (its
|
|
280
|
+
// robots.txt among them): the forum decides what its page is made of.
|
|
281
|
+
// Editor leftovers and hidden files are not part of it.
|
|
282
|
+
for (const f of fs.readdirSync(path.join(root, 'node_modules/fedipod-bb/site'))) {
|
|
283
|
+
if (/^[.#]|~$/u.test(f)) continue;
|
|
284
|
+
cp(`node_modules/fedipod-bb/site/${f}`, `bb/${f}`);
|
|
285
|
+
}
|
|
271
286
|
cp('web/admin/tokens.css', 'bb/tokens.css'); // the site's shared palette, size and family
|
|
272
287
|
cp('node_modules/fediverse-session/oidc-session.mjs', 'bb/solid-oidc-session.mjs'); // signing in to a pod: the library the forum's binding names
|
|
273
288
|
// The account library's demo page, with the library beside it, under /demo/:
|
|
@@ -292,15 +307,22 @@ cp(path.join(sessionRepo, 'demo.html'), 'demo/index.html');
|
|
|
292
307
|
const sub = (n, pairs) => { let t = fs.readFileSync(bb(n), 'utf8');
|
|
293
308
|
for (const [a, b] of pairs) t = t.split(a).join(b); fs.writeFileSync(bb(n), t); };
|
|
294
309
|
sub('oidc-session.mjs', [["'fediverse-session/oidc-session.mjs'", `'./solid-oidc-session.mjs?v=${stamp('solid-oidc-session.mjs')}'`]]);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
310
|
+
// Every module names the others it loads by their stamped address, and each
|
|
311
|
+
// is stamped only after the ones it names, so one module is one address
|
|
312
|
+
// wherever it is loaded from.
|
|
313
|
+
const modules = fs.readdirSync(path.join(site, 'bb')).filter((n) => /\.m?js$/u.test(n));
|
|
314
|
+
const loads = (n) => [...fs.readFileSync(bb(n), 'utf8').matchAll(/from '\.\/([^'?]+)'/gu)].map((m) => m[1]).filter((m) => modules.includes(m));
|
|
315
|
+
const done = new Set();
|
|
316
|
+
while (done.size < modules.length) {
|
|
317
|
+
const ready = modules.filter((n) => !done.has(n) && loads(n).every((m) => done.has(m)));
|
|
318
|
+
if (!ready.length) throw new Error(`stage-site: the forum page's modules load each other in a circle (${modules.filter((n) => !done.has(n)).join(', ')})`);
|
|
319
|
+
for (const n of ready) {
|
|
320
|
+
sub(n, loads(n).map((m) => [`'./${m}'`, `'./${m}?v=${stamp(m)}'`]));
|
|
321
|
+
done.add(n);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
sub('index.html', [...fs.readFileSync(bb('index.html'), 'utf8').matchAll(/"\/bb\/([^"?]+)"/gu)].map((m) => m[1])
|
|
325
|
+
.filter((n) => fs.existsSync(bb(n))).map((n) => [`"/bb/${n}"`, `"/bb/${n}?v=${stamp(n)}"`]));
|
|
304
326
|
}
|
|
305
327
|
injectUpdate(path.join(site, 'bb/index.html'));
|
|
306
328
|
injectUpdate(path.join(site, 'index.html'));
|
package/web/admin/record.js
CHANGED
|
@@ -108,6 +108,37 @@ document.getElementById('win').addEventListener('win:closed', () => {
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
// One row per list, indented under the lists row: its name, and one dropdown
|
|
112
|
+
// whose two options are its states — the list's people shown in Home, or kept
|
|
113
|
+
// to the list. Changing the word is the whole action.
|
|
114
|
+
function listRows() {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const l of config.lists || []) {
|
|
117
|
+
const dt = document.createElement('dt');
|
|
118
|
+
dt.className = 'under';
|
|
119
|
+
dt.textContent = l.title;
|
|
120
|
+
const sel = document.createElement('select');
|
|
121
|
+
sel.setAttribute('aria-label', `Posts from ${l.title} in your home timeline`);
|
|
122
|
+
sel.title = ' Whether the posts and boosts of the people on this list appear in your home timeline; hidden, they show only on the list and on the public timeline';
|
|
123
|
+
for (const [v, t] of [['show', 'shown in home'], ['hide', 'hidden from home']]) {
|
|
124
|
+
const o = document.createElement('option');
|
|
125
|
+
o.value = v; o.textContent = t; o.selected = (v === 'hide') === !!l.exclusive;
|
|
126
|
+
sel.appendChild(o);
|
|
127
|
+
}
|
|
128
|
+
sel.addEventListener('change', async () => {
|
|
129
|
+
const hide = sel.value === 'hide';
|
|
130
|
+
sel.disabled = true;
|
|
131
|
+
const r = await write('/lists', { id: l.id, exclusive: hide },
|
|
132
|
+
hide ? `${l.title} is hidden from your home timeline` : `${l.title} is shown in your home timeline`);
|
|
133
|
+
if (r) await load(); else { sel.value = l.exclusive ? 'hide' : 'show'; sel.disabled = false; }
|
|
134
|
+
});
|
|
135
|
+
const dd = document.createElement('dd');
|
|
136
|
+
dd.append(sel);
|
|
137
|
+
out.push([dt, dd]);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
111
142
|
// One row per identity connected elsewhere, indented under the row that adds
|
|
112
143
|
// them. Each carries its own way out: disconnecting one has nothing to do with
|
|
113
144
|
// the others.
|
|
@@ -254,7 +285,7 @@ function render() {
|
|
|
254
285
|
if (config.movedTo) rows.push(['moved to', config.movedTo]);
|
|
255
286
|
// A person gates followers here; a group's gate is the joins control on its
|
|
256
287
|
// kind row, so the row would be a second switch for the same thing.
|
|
257
|
-
if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl'], ['hashtag posts in home', 'ctl']);
|
|
288
|
+
if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl'], ['hashtag posts in home', 'ctl'], ['lists', 'ctl']);
|
|
258
289
|
// Rows that belong under the one being written, appended after it.
|
|
259
290
|
let kids = null;
|
|
260
291
|
for (const [k, v, link] of rows) {
|
|
@@ -301,6 +332,13 @@ function render() {
|
|
|
301
332
|
HOME_TAGS_CTL.hidden = false;
|
|
302
333
|
HOME_TAGS_PICK.value = config.tagsInHome !== false ? 'show' : 'hide';
|
|
303
334
|
}
|
|
335
|
+
// How many lists; each one is a row of its own underneath with its Home
|
|
336
|
+
// switch. Lists are made and filled in the client, so this row only counts.
|
|
337
|
+
if (k === 'lists') {
|
|
338
|
+
const n = (config.lists || []).length;
|
|
339
|
+
dd.textContent = n ? `${n} ${n === 1 ? 'list' : 'lists'}` : 'none — make one in Phanpy';
|
|
340
|
+
kids = listRows();
|
|
341
|
+
}
|
|
304
342
|
if (k === 'gateway') {
|
|
305
343
|
dd.textContent = '';
|
|
306
344
|
dd.append(GATEWAY_CTL);
|
package/web/app/admin-facade.mjs
CHANGED
|
@@ -23,12 +23,15 @@ import { followActor, followHandle } from '../../lib/core/social.mjs';
|
|
|
23
23
|
const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
24
24
|
// Carried in the actor document, so a change is not live until republished.
|
|
25
25
|
const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'aliases', 'autoAcceptFollows'];
|
|
26
|
+
// A list as the page shows it: its name and whether its people are kept out
|
|
27
|
+
// of Home. Lists are made and filled in the client; the page only switches.
|
|
28
|
+
const listRow = (l) => ({ id: l.id, title: l.title, exclusive: !!l.exclusive });
|
|
26
29
|
|
|
27
30
|
// The data endpoints this agent answers at the origin root (the page computes
|
|
28
31
|
// its base as the origin, so it asks for /status, not /admin/status). Anything
|
|
29
32
|
// not here falls through to the network as a static file or the gateway.
|
|
30
33
|
export const ADMIN_PATHS = new Set([
|
|
31
|
-
'/status', '/config', '/gateway', '/alias', '/rotate-key', '/import',
|
|
34
|
+
'/status', '/config', '/gateway', '/alias', '/rotate-key', '/import', '/lists',
|
|
32
35
|
'/deadletter', '/trouble', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
|
|
33
36
|
'/atproto', '/atproto/connect', '/atproto/disconnect',
|
|
34
37
|
'/rebuild', '/move', '/retire', '/inbox/prune', '/park', '/revive', '/takeover',
|
|
@@ -121,6 +124,7 @@ export class AdminFacade {
|
|
|
121
124
|
aliases: cfg.aliases || [],
|
|
122
125
|
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
123
126
|
tagsInHome: cfg.tagsInHome !== false,
|
|
127
|
+
lists: a.store.getLists().map(listRow),
|
|
124
128
|
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
125
129
|
mode: a.status().mode,
|
|
126
130
|
// Live from the credential's actual backend on THIS browser (a
|
|
@@ -242,6 +246,19 @@ export class AdminFacade {
|
|
|
242
246
|
});
|
|
243
247
|
}
|
|
244
248
|
|
|
249
|
+
// One list's Home switch, the same `exclusive` a Mastodon app sets.
|
|
250
|
+
case '/lists': {
|
|
251
|
+
if (typeof body.exclusive !== 'boolean') return json(400, { error: 'exclusive (true or false) is the one thing to set here' });
|
|
252
|
+
await a.requestTakeover?.();
|
|
253
|
+
const lists = a.store.getLists();
|
|
254
|
+
const l = lists.find(x => x.id === String(body.id || ''));
|
|
255
|
+
if (!l) return json(404, { error: 'no such list' });
|
|
256
|
+
l.exclusive = body.exclusive;
|
|
257
|
+
a.store.setLists(lists);
|
|
258
|
+
await a.store.flush();
|
|
259
|
+
return json(200, { ok: true, lists: lists.map(listRow) });
|
|
260
|
+
}
|
|
261
|
+
|
|
245
262
|
case '/describe': {
|
|
246
263
|
const cfg = { ...a.store.getConfig() };
|
|
247
264
|
if ('summary' in body) cfg.summary = body.summary || undefined;
|
package/web/app/dist/sw.js
CHANGED
|
@@ -69313,7 +69313,7 @@ async function handle3(api, ctx) {
|
|
|
69313
69313
|
if (mAccLists && req.method === "GET") {
|
|
69314
69314
|
const actorUrl = api.store.urlFor(mAccLists[1]);
|
|
69315
69315
|
if (!actorUrl) return send(404, { error: "Record not found" });
|
|
69316
|
-
return send(200, api.store.getLists().filter((l) => (l.members || []).includes(actorUrl)).map((l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || "list", exclusive:
|
|
69316
|
+
return send(200, api.store.getLists().filter((l) => (l.members || []).includes(actorUrl)).map((l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || "list", exclusive: !!l.exclusive })));
|
|
69317
69317
|
}
|
|
69318
69318
|
if (pathname === "/api/v1/mutes" && req.method === "GET") {
|
|
69319
69319
|
return send(200, (api.store.getMuted().actors || []).map((a) => api.account(a)));
|
|
@@ -69382,9 +69382,10 @@ async function handle4(api, ctx) {
|
|
|
69382
69382
|
if (pathname === "/api/v1/timelines/home" || pathname === "/api/v1/timelines/public" || pathname === "/api/v1/trends/statuses") {
|
|
69383
69383
|
const localOnly = pathname === "/api/v1/timelines/public" && url.searchParams.get("local") === "true";
|
|
69384
69384
|
const hideTags = pathname === "/api/v1/timelines/home" && api.store.getConfig?.()?.tagsInHome === false;
|
|
69385
|
+
const hiddenBy = new Set(pathname === "/api/v1/timelines/home" ? (api.store.getLists?.() || []).filter((l) => l.exclusive).flatMap((l) => l.members || []) : []);
|
|
69385
69386
|
const all = api.store.getStatuses();
|
|
69386
69387
|
const muted = new Set(api.store.getMuted().actors);
|
|
69387
|
-
let items = all.filter((s) => s.kind !== "remote" && s.kind !== "mention").filter((s) => !s.direct && s.visibility !== "direct").filter((s) => !muted.has(s.actor) && !muted.has(s.via)).filter((s) => !hideTags || s.kind !== "tag" || s.sourceAccts?.length).filter((s) => !localOnly || s.kind === "post" && s.visibility !== "private").sort((a, b) => String(b.published || "").localeCompare(String(a.published || "")));
|
|
69388
|
+
let items = all.filter((s) => s.kind !== "remote" && s.kind !== "mention").filter((s) => !s.direct && s.visibility !== "direct").filter((s) => !muted.has(s.actor) && !muted.has(s.via)).filter((s) => !hideTags || s.kind !== "tag" || s.sourceAccts?.length).filter((s) => !hiddenBy.size || !(hiddenBy.has(s.actor) || hiddenBy.has(s.via))).filter((s) => !localOnly || s.kind === "post" && s.visibility !== "private").sort((a, b) => String(b.published || "").localeCompare(String(a.published || "")));
|
|
69388
69389
|
const { items: page2, headers } = api.page(items, url);
|
|
69389
69390
|
return send(200, page2.map((s) => api.statusOrBoost(s, { all })), headers);
|
|
69390
69391
|
}
|
|
@@ -69452,7 +69453,8 @@ async function handle4(api, ctx) {
|
|
|
69452
69453
|
const { items: page2, headers } = api.page(items, url);
|
|
69453
69454
|
return send(200, page2.map((s) => api.statusOrBoost(s, { all })), headers);
|
|
69454
69455
|
}
|
|
69455
|
-
const
|
|
69456
|
+
const flag = (v) => v === true || v === "true" || v === "on" || v === "1";
|
|
69457
|
+
const listJson = (l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || "list", exclusive: !!l.exclusive });
|
|
69456
69458
|
if (pathname === "/api/v1/lists" && req.method === "GET") {
|
|
69457
69459
|
return send(200, api.store.getLists().map(listJson));
|
|
69458
69460
|
}
|
|
@@ -69461,7 +69463,13 @@ async function handle4(api, ctx) {
|
|
|
69461
69463
|
const title = String(body.title || "").trim();
|
|
69462
69464
|
if (!title) return send(422, { error: "a title is required" });
|
|
69463
69465
|
const lists = api.store.getLists();
|
|
69464
|
-
const l = {
|
|
69466
|
+
const l = {
|
|
69467
|
+
id: node_crypto_default.randomBytes(8).toString("hex"),
|
|
69468
|
+
title,
|
|
69469
|
+
repliesPolicy: body.replies_policy || "list",
|
|
69470
|
+
exclusive: flag(body.exclusive),
|
|
69471
|
+
members: []
|
|
69472
|
+
};
|
|
69465
69473
|
lists.push(l);
|
|
69466
69474
|
api.store.setLists(lists);
|
|
69467
69475
|
return send(200, listJson(l));
|
|
@@ -69479,6 +69487,7 @@ async function handle4(api, ctx) {
|
|
|
69479
69487
|
const body = await readBody2(req);
|
|
69480
69488
|
if (body.title) l.title = String(body.title);
|
|
69481
69489
|
if (body.replies_policy) l.repliesPolicy = body.replies_policy;
|
|
69490
|
+
if ("exclusive" in body) l.exclusive = flag(body.exclusive);
|
|
69482
69491
|
api.store.setLists(lists);
|
|
69483
69492
|
}
|
|
69484
69493
|
return send(200, listJson(l));
|
|
@@ -71622,6 +71631,7 @@ var ImportWorker = class {
|
|
|
71622
71631
|
// web/app/admin-facade.mjs
|
|
71623
71632
|
var PERMANENT_CONFIG = ["handle", "remotePod", "issuer", "root", "kind"];
|
|
71624
71633
|
var WIRE_CONFIG = ["name", "summary", "icon", "image", "fields", "aliases", "autoAcceptFollows"];
|
|
71634
|
+
var listRow = (l) => ({ id: l.id, title: l.title, exclusive: !!l.exclusive });
|
|
71625
71635
|
var ADMIN_PATHS = /* @__PURE__ */ new Set([
|
|
71626
71636
|
"/status",
|
|
71627
71637
|
"/config",
|
|
@@ -71629,6 +71639,7 @@ var ADMIN_PATHS = /* @__PURE__ */ new Set([
|
|
|
71629
71639
|
"/alias",
|
|
71630
71640
|
"/rotate-key",
|
|
71631
71641
|
"/import",
|
|
71642
|
+
"/lists",
|
|
71632
71643
|
"/deadletter",
|
|
71633
71644
|
"/trouble",
|
|
71634
71645
|
"/blocks",
|
|
@@ -71733,6 +71744,7 @@ var AdminFacade = class {
|
|
|
71733
71744
|
aliases: cfg.aliases || [],
|
|
71734
71745
|
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
71735
71746
|
tagsInHome: cfg.tagsInHome !== false,
|
|
71747
|
+
lists: a.store.getLists().map(listRow),
|
|
71736
71748
|
quiescedAt: cfg.quiescedAt || null,
|
|
71737
71749
|
movedTo: cfg.movedTo || null,
|
|
71738
71750
|
mode: a.status().mode,
|
|
@@ -71876,6 +71888,18 @@ var AdminFacade = class {
|
|
|
71876
71888
|
...pub?.unreachable?.length ? { unreachable: pub.unreachable } : {}
|
|
71877
71889
|
});
|
|
71878
71890
|
}
|
|
71891
|
+
// One list's Home switch, the same `exclusive` a Mastodon app sets.
|
|
71892
|
+
case "/lists": {
|
|
71893
|
+
if (typeof body.exclusive !== "boolean") return json2(400, { error: "exclusive (true or false) is the one thing to set here" });
|
|
71894
|
+
await a.requestTakeover?.();
|
|
71895
|
+
const lists = a.store.getLists();
|
|
71896
|
+
const l = lists.find((x) => x.id === String(body.id || ""));
|
|
71897
|
+
if (!l) return json2(404, { error: "no such list" });
|
|
71898
|
+
l.exclusive = body.exclusive;
|
|
71899
|
+
a.store.setLists(lists);
|
|
71900
|
+
await a.store.flush();
|
|
71901
|
+
return json2(200, { ok: true, lists: lists.map(listRow) });
|
|
71902
|
+
}
|
|
71879
71903
|
case "/describe": {
|
|
71880
71904
|
const cfg = { ...a.store.getConfig() };
|
|
71881
71905
|
if ("summary" in body) cfg.summary = body.summary || void 0;
|